✨ feat(main_loop): harden plan execution state
Validate required Plan Meta before claims, record claim ownership, and persist verification evidence on completion. Document conditional skill triggers without adding them to automatic main-loop dispatch.
This commit is contained in:
+192
-17
@@ -1,8 +1,11 @@
|
||||
#!/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
|
||||
@@ -31,6 +34,22 @@ PLAN_LINE_RE = re.compile(
|
||||
)
|
||||
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",
|
||||
@@ -48,7 +67,8 @@ def usage() -> str:
|
||||
"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>]\n"
|
||||
"-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"
|
||||
@@ -63,6 +83,8 @@ def usage() -> str:
|
||||
" -executor NAME\n"
|
||||
" -constraints CSV\n"
|
||||
" -note TEXT\n"
|
||||
" -owner NAME\n"
|
||||
" -verified TEXT\n"
|
||||
" -h, -help Show this help.\n"
|
||||
)
|
||||
|
||||
@@ -98,6 +120,24 @@ 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
|
||||
@@ -188,18 +228,23 @@ def render_workflow_state_lines(
|
||||
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]
|
||||
if phase:
|
||||
lines.append(f"phase: {phase}")
|
||||
if spec:
|
||||
lines.append(f"spec: {spec}")
|
||||
if plan:
|
||||
lines.append(f"plan: {plan}")
|
||||
if executor:
|
||||
lines.append(f"executor: {executor}")
|
||||
if constraints:
|
||||
lines.append(f"constraints: {constraints}")
|
||||
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
|
||||
|
||||
@@ -213,7 +258,7 @@ def parse_workflow_state(
|
||||
if ": " not in line:
|
||||
continue
|
||||
key, value = line.split(": ", 1)
|
||||
if key in {"phase", "spec", "plan", "executor", "constraints"}:
|
||||
if key in WORKFLOW_STATE_KEYS:
|
||||
state[key] = value
|
||||
return state
|
||||
|
||||
@@ -348,9 +393,15 @@ def update_workflow_state(
|
||||
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:
|
||||
@@ -361,12 +412,21 @@ def update_workflow_state(
|
||||
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
|
||||
|
||||
@@ -399,6 +459,27 @@ def filter_existing_entries(
|
||||
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],
|
||||
@@ -427,13 +508,18 @@ def choose_claim_entry(
|
||||
return None
|
||||
|
||||
|
||||
def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
||||
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)
|
||||
@@ -458,6 +544,9 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
||||
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)
|
||||
|
||||
@@ -468,12 +557,18 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
||||
|
||||
|
||||
def finish_plan(
|
||||
plan: str, status: str, progress_path: Path, note: Optional[str]
|
||||
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):
|
||||
@@ -488,6 +583,15 @@ def finish_plan(
|
||||
|
||||
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",
|
||||
@@ -502,6 +606,8 @@ def finish_plan(
|
||||
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
|
||||
@@ -511,11 +617,64 @@ def finish_plan(
|
||||
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,
|
||||
@@ -543,7 +702,7 @@ def main(argv: list[str]) -> int:
|
||||
return 0
|
||||
|
||||
mode = argv[0]
|
||||
if mode not in {"claim", "finish", "record"}:
|
||||
if mode not in {"claim", "finish", "record", "status"}:
|
||||
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
@@ -561,11 +720,26 @@ def main(argv: list[str]) -> int:
|
||||
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))
|
||||
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
|
||||
@@ -596,11 +770,12 @@ def main(argv: list[str]) -> int:
|
||||
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)
|
||||
code, message = finish_plan(plan, status, Path(progress), note, verified)
|
||||
if code != 0:
|
||||
print(message, file=sys.stderr)
|
||||
return code
|
||||
|
||||
Reference in New Issue
Block a user