diff --git a/.github/workflows/autoloop.lock.yml b/.github/workflows/autoloop.lock.yml index f9dd301..f8a6564 100644 --- a/.github/workflows/autoloop.lock.yml +++ b/.github/workflows/autoloop.lock.yml @@ -429,7 +429,8 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_TOKEN: ${{ github.token }} name: Check which programs are due - run: "python3 - << 'PYEOF'\nimport os, json, re, glob, sys\nimport urllib.request, urllib.error\nfrom datetime import datetime, timezone, timedelta\n\nprograms_dir = \".autoloop/programs\"\nautoloop_dir = \".autoloop/programs\"\ntemplate_file = os.path.join(autoloop_dir, \"example.md\")\n\n# Read program state from repo-memory (persistent git-backed storage)\ngithub_token = os.environ.get(\"GITHUB_TOKEN\", \"\")\nrepo = os.environ.get(\"GITHUB_REPOSITORY\", \"\")\nforced_program = os.environ.get(\"AUTOLOOP_PROGRAM\", \"\").strip()\n\n# Repo-memory files are cloned to /tmp/gh-aw/repo-memory/{id}/ where {id}\n# is derived from the branch-name configured in the tools section (memory/autoloop → autoloop)\nrepo_memory_dir = \"/tmp/gh-aw/repo-memory/autoloop\"\n\ndef parse_machine_state(content):\n \"\"\"Parse the ⚙️ Machine State table from a state file. Returns a dict.\"\"\"\n state = {}\n m = re.search(r'## ⚙️ Machine State.*?\\n(.*?)(?=\\n## |\\Z)', content, re.DOTALL)\n if not m:\n return state\n section = m.group(0)\n for row in re.finditer(r'\\|\\s*(.+?)\\s*\\|\\s*(.+?)\\s*\\|', section):\n raw_key = row.group(1).strip()\n raw_val = row.group(2).strip()\n if raw_key.lower() in (\"field\", \"---\", \":---\", \":---:\", \"---:\"):\n continue\n key = raw_key.lower().replace(\" \", \"_\")\n val = None if raw_val in (\"—\", \"-\", \"\") else raw_val\n state[key] = val\n # Coerce types\n for int_field in (\"iteration_count\", \"consecutive_errors\"):\n if int_field in state:\n try:\n state[int_field] = int(state[int_field])\n except (ValueError, TypeError):\n state[int_field] = 0\n if \"paused\" in state:\n state[\"paused\"] = str(state.get(\"paused\", \"\")).lower() == \"true\"\n if \"completed\" in state:\n state[\"completed\"] = str(state.get(\"completed\", \"\")).lower() == \"true\"\n # recent_statuses: stored as comma-separated words (e.g. \"accepted, rejected, error\")\n rs_raw = state.get(\"recent_statuses\") or \"\"\n if rs_raw:\n state[\"recent_statuses\"] = [s.strip().lower() for s in rs_raw.split(\",\") if s.strip()]\n else:\n state[\"recent_statuses\"] = []\n return state\n\ndef read_program_state(program_name):\n \"\"\"Read scheduling state from the repo-memory state file.\"\"\"\n state_file = os.path.join(repo_memory_dir, f\"{program_name}.md\")\n if not os.path.isfile(state_file):\n print(f\" {program_name}: no state file found (first run)\")\n return {}\n with open(state_file, encoding=\"utf-8\") as f:\n content = f.read()\n return parse_machine_state(content)\n\n# Bootstrap: create autoloop programs directory and template if missing\nif not os.path.isdir(autoloop_dir):\n os.makedirs(autoloop_dir, exist_ok=True)\n bt = chr(96) # backtick — avoid literal backticks that break gh-aw compiler\n template = \"\\n\".join([\n \"\",\n \"\",\n \"\",\n \"\",\n \"# Autoloop Program\",\n \"\",\n \"\",\n \"\",\n \"## Goal\",\n \"\",\n \"\",\n \"\",\n \"REPLACE THIS with your optimization goal.\",\n \"\",\n \"## Target\",\n \"\",\n \"\",\n \"\",\n \"Only modify these files:\",\n f\"- {bt}REPLACE_WITH_FILE{bt} -- (describe what this file does)\",\n \"\",\n \"Do NOT modify:\",\n \"- (list files that must not be touched)\",\n \"\",\n \"## Evaluation\",\n \"\",\n \"\",\n \"\",\n f\"{bt}{bt}{bt}bash\",\n \"REPLACE_WITH_YOUR_EVALUATION_COMMAND\",\n f\"{bt}{bt}{bt}\",\n \"\",\n f\"The metric is {bt}REPLACE_WITH_METRIC_NAME{bt}. **Lower/Higher is better.** (pick one)\",\n \"\",\n ])\n with open(template_file, \"w\") as f:\n f.write(template)\n # Leave the template unstaged — the agent will create a draft PR with it\n print(f\"BOOTSTRAPPED: created {template_file} locally (agent will create a draft PR)\")\n\n# Find all program files from all locations:\n# 1. Directory-based programs: .autoloop/programs//program.md (preferred)\n# 2. Bare markdown programs: .autoloop/programs/.md (simple)\n# 3. Issue-based programs: GitHub issues with the 'autoloop-program' label\nprogram_files = []\nissue_programs = {} # name -> {issue_number, file}\n\n# Scan .autoloop/programs/ for directory-based programs\nif os.path.isdir(programs_dir):\n for entry in sorted(os.listdir(programs_dir)):\n prog_dir = os.path.join(programs_dir, entry)\n if os.path.isdir(prog_dir):\n # Look for program.md inside the directory\n prog_file = os.path.join(prog_dir, \"program.md\")\n if os.path.isfile(prog_file):\n program_files.append(prog_file)\n\n# Scan .autoloop/programs/ for bare markdown programs\nbare_programs = sorted(glob.glob(os.path.join(autoloop_dir, \"*.md\")))\nfor pf in bare_programs:\n program_files.append(pf)\n\n# Scan GitHub issues with the 'autoloop-program' label\nissue_programs_dir = \"/tmp/gh-aw/issue-programs\"\nos.makedirs(issue_programs_dir, exist_ok=True)\ntry:\n api_url = f\"https://api.github.com/repos/{repo}/issues?labels=autoloop-program&state=open&per_page=100\"\n req = urllib.request.Request(api_url, headers={\n \"Authorization\": f\"token {github_token}\",\n \"Accept\": \"application/vnd.github.v3+json\",\n })\n with urllib.request.urlopen(req, timeout=30) as resp:\n issues = json.loads(resp.read().decode())\n for issue in issues:\n if issue.get(\"pull_request\"):\n continue # skip PRs\n body = issue.get(\"body\") or \"\"\n title = issue.get(\"title\") or \"\"\n number = issue[\"number\"]\n # Derive program name from issue title: slugify to lowercase with hyphens\n slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')\n slug = re.sub(r'-+', '-', slug) # collapse consecutive hyphens\n if not slug:\n slug = f\"issue-{number}\"\n # Avoid slug collisions: if another issue already claimed this slug, append issue number\n if slug in issue_programs:\n print(f\" Warning: slug '{slug}' (issue #{number}) collides with issue #{issue_programs[slug]['issue_number']}, appending issue number\")\n slug = f\"{slug}-{number}\"\n # Write issue body to a temp file so the scheduling loop can process it\n issue_file = os.path.join(issue_programs_dir, f\"{slug}.md\")\n with open(issue_file, \"w\") as f:\n f.write(body)\n program_files.append(issue_file)\n issue_programs[slug] = {\"issue_number\": number, \"file\": issue_file, \"title\": title}\n print(f\" Found issue-based program: '{slug}' (issue #{number})\")\nexcept Exception as e:\n print(f\" Warning: could not fetch issue-based programs: {e}\")\n\nif not program_files:\n # Fallback to single-file locations\n for path in [\".autoloop/program.md\", \"program.md\"]:\n if os.path.isfile(path):\n program_files = [path]\n break\n\nif not program_files:\n print(\"NO_PROGRAMS_FOUND\")\n os.makedirs(\"/tmp/gh-aw\", exist_ok=True)\n with open(\"/tmp/gh-aw/autoloop.json\", \"w\") as f:\n json.dump({\"due\": [], \"skipped\": [], \"unconfigured\": [], \"no_programs\": True}, f)\n sys.exit(0)\n\nos.makedirs(\"/tmp/gh-aw\", exist_ok=True)\nnow = datetime.now(timezone.utc)\ndue = []\nskipped = []\nunconfigured = []\nall_programs = {} # name -> file path (populated during scanning)\n\n# Schedule string to timedelta\ndef parse_schedule(s):\n s = s.strip().lower()\n m = re.match(r\"every\\s+(\\d+)\\s*h\", s)\n if m:\n return timedelta(hours=int(m.group(1)))\n m = re.match(r\"every\\s+(\\d+)\\s*m\", s)\n if m:\n return timedelta(minutes=int(m.group(1)))\n if s == \"daily\":\n return timedelta(hours=24)\n if s == \"weekly\":\n return timedelta(days=7)\n return None # No per-program schedule — always due\n\ndef get_program_name(pf):\n \"\"\"Extract program name from file path.\n Directory-based: .autoloop/programs//program.md -> \n Bare markdown: .autoloop/programs/.md -> \n Issue-based: /tmp/gh-aw/issue-programs/.md -> \n \"\"\"\n if pf.endswith(\"/program.md\"):\n # Directory-based program: name is the parent directory\n return os.path.basename(os.path.dirname(pf))\n else:\n # Bare markdown or issue-based program: name is the filename without .md\n return os.path.splitext(os.path.basename(pf))[0]\n\nfor pf in program_files:\n name = get_program_name(pf)\n all_programs[name] = pf\n with open(pf) as f:\n content = f.read()\n\n # Check sentinel (skip for issue-based programs which use AUTOLOOP:ISSUE-PROGRAM)\n if \"\" in content:\n unconfigured.append(name)\n continue\n\n # Check for TODO/REPLACE placeholders\n if re.search(r'\\bTODO\\b|\\bREPLACE', content):\n unconfigured.append(name)\n continue\n\n # Parse optional YAML frontmatter for schedule and target-metric\n # Strip leading HTML comments before checking (issue-based programs may have them)\n content_stripped = re.sub(r'^(\\s*\\s*\\n)*', '', content, flags=re.DOTALL)\n schedule_delta = None\n target_metric = None\n fm_match = re.match(r\"^---\\s*\\n(.*?)\\n---\\s*\\n\", content_stripped, re.DOTALL)\n if fm_match:\n for line in fm_match.group(1).split(\"\\n\"):\n if line.strip().startswith(\"schedule:\"):\n schedule_str = line.split(\":\", 1)[1].strip()\n schedule_delta = parse_schedule(schedule_str)\n if line.strip().startswith(\"target-metric:\"):\n try:\n target_metric = float(line.split(\":\", 1)[1].strip())\n except (ValueError, TypeError):\n print(f\" Warning: {name} has invalid target-metric value: {line.split(':', 1)[1].strip()}\")\n\n # Read state from repo-memory\n state = read_program_state(name)\n if state:\n print(f\" {name}: last_run={state.get('last_run')}, iteration_count={state.get('iteration_count')}\")\n else:\n print(f\" {name}: no state found (first run)\")\n\n last_run = None\n lr = state.get(\"last_run\")\n if lr:\n try:\n last_run = datetime.fromisoformat(lr.replace(\"Z\", \"+00:00\"))\n except ValueError:\n pass\n\n # Check if completed (target metric was reached)\n if str(state.get(\"completed\", \"\")).lower() == \"true\":\n skipped.append({\"name\": name, \"reason\": f\"completed: target metric reached\"})\n continue\n\n # Check if paused (e.g., plateau or recurring errors)\n if state.get(\"paused\"):\n skipped.append({\"name\": name, \"reason\": f\"paused: {state.get('pause_reason', 'unknown')}\"})\n continue\n\n # Auto-pause on plateau: 5+ consecutive rejections\n recent = state.get(\"recent_statuses\", [])[-5:]\n if len(recent) >= 5 and all(s == \"rejected\" for s in recent):\n skipped.append({\"name\": name, \"reason\": \"plateau: 5 consecutive rejections\"})\n continue\n\n # Check if due based on per-program schedule\n if schedule_delta and last_run:\n if now - last_run < schedule_delta:\n skipped.append({\"name\": name, \"reason\": \"not due yet\",\n \"next_due\": (last_run + schedule_delta).isoformat()})\n continue\n\n due.append({\"name\": name, \"last_run\": lr, \"file\": pf, \"target_metric\": target_metric})\n\n# Pick the program to run\nselected = None\nselected_file = None\nselected_issue = None\nselected_target_metric = None\ndeferred = []\n\nif forced_program:\n # Manual dispatch requested a specific program — bypass scheduling\n # (paused, not-due, and plateau programs can still be forced)\n if forced_program not in all_programs:\n print(f\"ERROR: requested program '{forced_program}' not found.\")\n print(f\" Available programs: {list(all_programs.keys())}\")\n sys.exit(1)\n if forced_program in unconfigured:\n print(f\"ERROR: requested program '{forced_program}' is unconfigured (has placeholders).\")\n sys.exit(1)\n selected = forced_program\n selected_file = all_programs[forced_program]\n deferred = [p[\"name\"] for p in due if p[\"name\"] != forced_program]\n if selected in issue_programs:\n selected_issue = issue_programs[selected][\"issue_number\"]\n # Find target_metric: check the due list first, then parse from the program file\n for p in due:\n if p[\"name\"] == forced_program:\n selected_target_metric = p.get(\"target_metric\")\n break\n if selected_target_metric is None:\n # Program may have been skipped (completed/paused/plateau) — parse directly\n try:\n with open(selected_file) as _f:\n _content = _f.read()\n _content_stripped = re.sub(r'^(\\s*\\s*\\n)*', '', _content, flags=re.DOTALL)\n _fm = re.match(r\"^---\\s*\\n(.*?)\\n---\\s*\\n\", _content_stripped, re.DOTALL)\n if _fm:\n for _line in _fm.group(1).split(\"\\n\"):\n if _line.strip().startswith(\"target-metric:\"):\n selected_target_metric = float(_line.split(\":\", 1)[1].strip())\n break\n except (OSError, ValueError, TypeError):\n pass\n print(f\"FORCED: running program '{forced_program}' (manual dispatch)\")\nelif due:\n # Normal scheduling: pick the single most-overdue program\n due.sort(key=lambda p: p[\"last_run\"] or \"\") # None/empty sorts first (never run)\n selected = due[0][\"name\"]\n selected_file = due[0][\"file\"]\n selected_target_metric = due[0].get(\"target_metric\")\n deferred = [p[\"name\"] for p in due[1:]]\n # Check if the selected program is issue-based\n if selected in issue_programs:\n selected_issue = issue_programs[selected][\"issue_number\"]\n\nresult = {\n \"selected\": selected,\n \"selected_file\": selected_file,\n \"selected_issue\": selected_issue,\n \"selected_target_metric\": selected_target_metric,\n \"issue_programs\": {name: info[\"issue_number\"] for name, info in issue_programs.items()},\n \"deferred\": deferred,\n \"skipped\": skipped,\n \"unconfigured\": unconfigured,\n \"no_programs\": False,\n}\n\nos.makedirs(\"/tmp/gh-aw\", exist_ok=True)\nwith open(\"/tmp/gh-aw/autoloop.json\", \"w\") as f:\n json.dump(result, f, indent=2)\n\nprint(\"=== Autoloop Program Check ===\")\nprint(f\"Selected program: {selected or '(none)'} ({selected_file or 'n/a'})\")\nprint(f\"Deferred (next run): {deferred or '(none)'}\")\nprint(f\"Programs skipped: {[s['name'] for s in skipped] or '(none)'}\")\nprint(f\"Programs unconfigured: {unconfigured or '(none)'}\")\n\nif not selected and not unconfigured:\n print(\"\\nNo programs due this run. Exiting early.\")\n sys.exit(1) # Non-zero exit skips the agent step\nPYEOF\n" + run: | + python3 .github/workflows/scripts/autoloop_scheduler.py # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) diff --git a/.github/workflows/autoloop.md b/.github/workflows/autoloop.md index e564101..c3a22f0 100644 --- a/.github/workflows/autoloop.md +++ b/.github/workflows/autoloop.md @@ -87,374 +87,7 @@ steps: GITHUB_REPOSITORY: ${{ github.repository }} AUTOLOOP_PROGRAM: ${{ github.event.inputs.program }} run: | - python3 - << 'PYEOF' - import os, json, re, glob, sys - import urllib.request, urllib.error - from datetime import datetime, timezone, timedelta - - programs_dir = ".autoloop/programs" - autoloop_dir = ".autoloop/programs" - template_file = os.path.join(autoloop_dir, "example.md") - - # Read program state from repo-memory (persistent git-backed storage) - github_token = os.environ.get("GITHUB_TOKEN", "") - repo = os.environ.get("GITHUB_REPOSITORY", "") - forced_program = os.environ.get("AUTOLOOP_PROGRAM", "").strip() - - # Repo-memory files are cloned to /tmp/gh-aw/repo-memory/{id}/ where {id} - # is derived from the branch-name configured in the tools section (memory/autoloop → autoloop) - repo_memory_dir = "/tmp/gh-aw/repo-memory/autoloop" - - def parse_machine_state(content): - """Parse the ⚙️ Machine State table from a state file. Returns a dict.""" - state = {} - m = re.search(r'## ⚙️ Machine State.*?\n(.*?)(?=\n## |\Z)', content, re.DOTALL) - if not m: - return state - section = m.group(0) - for row in re.finditer(r'\|\s*(.+?)\s*\|\s*(.+?)\s*\|', section): - raw_key = row.group(1).strip() - raw_val = row.group(2).strip() - if raw_key.lower() in ("field", "---", ":---", ":---:", "---:"): - continue - key = raw_key.lower().replace(" ", "_") - val = None if raw_val in ("—", "-", "") else raw_val - state[key] = val - # Coerce types - for int_field in ("iteration_count", "consecutive_errors"): - if int_field in state: - try: - state[int_field] = int(state[int_field]) - except (ValueError, TypeError): - state[int_field] = 0 - if "paused" in state: - state["paused"] = str(state.get("paused", "")).lower() == "true" - if "completed" in state: - state["completed"] = str(state.get("completed", "")).lower() == "true" - # recent_statuses: stored as comma-separated words (e.g. "accepted, rejected, error") - rs_raw = state.get("recent_statuses") or "" - if rs_raw: - state["recent_statuses"] = [s.strip().lower() for s in rs_raw.split(",") if s.strip()] - else: - state["recent_statuses"] = [] - return state - - def read_program_state(program_name): - """Read scheduling state from the repo-memory state file.""" - state_file = os.path.join(repo_memory_dir, f"{program_name}.md") - if not os.path.isfile(state_file): - print(f" {program_name}: no state file found (first run)") - return {} - with open(state_file, encoding="utf-8") as f: - content = f.read() - return parse_machine_state(content) - - # Bootstrap: create autoloop programs directory and template if missing - if not os.path.isdir(autoloop_dir): - os.makedirs(autoloop_dir, exist_ok=True) - bt = chr(96) # backtick — avoid literal backticks that break gh-aw compiler - template = "\n".join([ - "", - "", - "", - "", - "# Autoloop Program", - "", - "", - "", - "## Goal", - "", - "", - "", - "REPLACE THIS with your optimization goal.", - "", - "## Target", - "", - "", - "", - "Only modify these files:", - f"- {bt}REPLACE_WITH_FILE{bt} -- (describe what this file does)", - "", - "Do NOT modify:", - "- (list files that must not be touched)", - "", - "## Evaluation", - "", - "", - "", - f"{bt}{bt}{bt}bash", - "REPLACE_WITH_YOUR_EVALUATION_COMMAND", - f"{bt}{bt}{bt}", - "", - f"The metric is {bt}REPLACE_WITH_METRIC_NAME{bt}. **Lower/Higher is better.** (pick one)", - "", - ]) - with open(template_file, "w") as f: - f.write(template) - # Leave the template unstaged — the agent will create a draft PR with it - print(f"BOOTSTRAPPED: created {template_file} locally (agent will create a draft PR)") - - # Find all program files from all locations: - # 1. Directory-based programs: .autoloop/programs//program.md (preferred) - # 2. Bare markdown programs: .autoloop/programs/.md (simple) - # 3. Issue-based programs: GitHub issues with the 'autoloop-program' label - program_files = [] - issue_programs = {} # name -> {issue_number, file} - - # Scan .autoloop/programs/ for directory-based programs - if os.path.isdir(programs_dir): - for entry in sorted(os.listdir(programs_dir)): - prog_dir = os.path.join(programs_dir, entry) - if os.path.isdir(prog_dir): - # Look for program.md inside the directory - prog_file = os.path.join(prog_dir, "program.md") - if os.path.isfile(prog_file): - program_files.append(prog_file) - - # Scan .autoloop/programs/ for bare markdown programs - bare_programs = sorted(glob.glob(os.path.join(autoloop_dir, "*.md"))) - for pf in bare_programs: - program_files.append(pf) - - # Scan GitHub issues with the 'autoloop-program' label - issue_programs_dir = "/tmp/gh-aw/issue-programs" - os.makedirs(issue_programs_dir, exist_ok=True) - try: - api_url = f"https://api.github.com/repos/{repo}/issues?labels=autoloop-program&state=open&per_page=100" - req = urllib.request.Request(api_url, headers={ - "Authorization": f"token {github_token}", - "Accept": "application/vnd.github.v3+json", - }) - with urllib.request.urlopen(req, timeout=30) as resp: - issues = json.loads(resp.read().decode()) - for issue in issues: - if issue.get("pull_request"): - continue # skip PRs - body = issue.get("body") or "" - title = issue.get("title") or "" - number = issue["number"] - # Derive program name from issue title: slugify to lowercase with hyphens - slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-') - slug = re.sub(r'-+', '-', slug) # collapse consecutive hyphens - if not slug: - slug = f"issue-{number}" - # Avoid slug collisions: if another issue already claimed this slug, append issue number - if slug in issue_programs: - print(f" Warning: slug '{slug}' (issue #{number}) collides with issue #{issue_programs[slug]['issue_number']}, appending issue number") - slug = f"{slug}-{number}" - # Write issue body to a temp file so the scheduling loop can process it - issue_file = os.path.join(issue_programs_dir, f"{slug}.md") - with open(issue_file, "w") as f: - f.write(body) - program_files.append(issue_file) - issue_programs[slug] = {"issue_number": number, "file": issue_file, "title": title} - print(f" Found issue-based program: '{slug}' (issue #{number})") - except Exception as e: - print(f" Warning: could not fetch issue-based programs: {e}") - - if not program_files: - # Fallback to single-file locations - for path in [".autoloop/program.md", "program.md"]: - if os.path.isfile(path): - program_files = [path] - break - - if not program_files: - print("NO_PROGRAMS_FOUND") - os.makedirs("/tmp/gh-aw", exist_ok=True) - with open("/tmp/gh-aw/autoloop.json", "w") as f: - json.dump({"due": [], "skipped": [], "unconfigured": [], "no_programs": True}, f) - sys.exit(0) - - os.makedirs("/tmp/gh-aw", exist_ok=True) - now = datetime.now(timezone.utc) - due = [] - skipped = [] - unconfigured = [] - all_programs = {} # name -> file path (populated during scanning) - - # Schedule string to timedelta - def parse_schedule(s): - s = s.strip().lower() - m = re.match(r"every\s+(\d+)\s*h", s) - if m: - return timedelta(hours=int(m.group(1))) - m = re.match(r"every\s+(\d+)\s*m", s) - if m: - return timedelta(minutes=int(m.group(1))) - if s == "daily": - return timedelta(hours=24) - if s == "weekly": - return timedelta(days=7) - return None # No per-program schedule — always due - - def get_program_name(pf): - """Extract program name from file path. - Directory-based: .autoloop/programs//program.md -> - Bare markdown: .autoloop/programs/.md -> - Issue-based: /tmp/gh-aw/issue-programs/.md -> - """ - if pf.endswith("/program.md"): - # Directory-based program: name is the parent directory - return os.path.basename(os.path.dirname(pf)) - else: - # Bare markdown or issue-based program: name is the filename without .md - return os.path.splitext(os.path.basename(pf))[0] - - for pf in program_files: - name = get_program_name(pf) - all_programs[name] = pf - with open(pf) as f: - content = f.read() - - # Check sentinel (skip for issue-based programs which use AUTOLOOP:ISSUE-PROGRAM) - if "" in content: - unconfigured.append(name) - continue - - # Check for TODO/REPLACE placeholders - if re.search(r'\bTODO\b|\bREPLACE', content): - unconfigured.append(name) - continue - - # Parse optional YAML frontmatter for schedule and target-metric - # Strip leading HTML comments before checking (issue-based programs may have them) - content_stripped = re.sub(r'^(\s*\s*\n)*', '', content, flags=re.DOTALL) - schedule_delta = None - target_metric = None - fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content_stripped, re.DOTALL) - if fm_match: - for line in fm_match.group(1).split("\n"): - if line.strip().startswith("schedule:"): - schedule_str = line.split(":", 1)[1].strip() - schedule_delta = parse_schedule(schedule_str) - if line.strip().startswith("target-metric:"): - try: - target_metric = float(line.split(":", 1)[1].strip()) - except (ValueError, TypeError): - print(f" Warning: {name} has invalid target-metric value: {line.split(':', 1)[1].strip()}") - - # Read state from repo-memory - state = read_program_state(name) - if state: - print(f" {name}: last_run={state.get('last_run')}, iteration_count={state.get('iteration_count')}") - else: - print(f" {name}: no state found (first run)") - - last_run = None - lr = state.get("last_run") - if lr: - try: - last_run = datetime.fromisoformat(lr.replace("Z", "+00:00")) - except ValueError: - pass - - # Check if completed (target metric was reached) - if str(state.get("completed", "")).lower() == "true": - skipped.append({"name": name, "reason": f"completed: target metric reached"}) - continue - - # Check if paused (e.g., plateau or recurring errors) - if state.get("paused"): - skipped.append({"name": name, "reason": f"paused: {state.get('pause_reason', 'unknown')}"}) - continue - - # Auto-pause on plateau: 5+ consecutive rejections - recent = state.get("recent_statuses", [])[-5:] - if len(recent) >= 5 and all(s == "rejected" for s in recent): - skipped.append({"name": name, "reason": "plateau: 5 consecutive rejections"}) - continue - - # Check if due based on per-program schedule - if schedule_delta and last_run: - if now - last_run < schedule_delta: - skipped.append({"name": name, "reason": "not due yet", - "next_due": (last_run + schedule_delta).isoformat()}) - continue - - due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric}) - - # Pick the program to run - selected = None - selected_file = None - selected_issue = None - selected_target_metric = None - deferred = [] - - if forced_program: - # Manual dispatch requested a specific program — bypass scheduling - # (paused, not-due, and plateau programs can still be forced) - if forced_program not in all_programs: - print(f"ERROR: requested program '{forced_program}' not found.") - print(f" Available programs: {list(all_programs.keys())}") - sys.exit(1) - if forced_program in unconfigured: - print(f"ERROR: requested program '{forced_program}' is unconfigured (has placeholders).") - sys.exit(1) - selected = forced_program - selected_file = all_programs[forced_program] - deferred = [p["name"] for p in due if p["name"] != forced_program] - if selected in issue_programs: - selected_issue = issue_programs[selected]["issue_number"] - # Find target_metric: check the due list first, then parse from the program file - for p in due: - if p["name"] == forced_program: - selected_target_metric = p.get("target_metric") - break - if selected_target_metric is None: - # Program may have been skipped (completed/paused/plateau) — parse directly - try: - with open(selected_file) as _f: - _content = _f.read() - _content_stripped = re.sub(r'^(\s*\s*\n)*', '', _content, flags=re.DOTALL) - _fm = re.match(r"^---\s*\n(.*?)\n---\s*\n", _content_stripped, re.DOTALL) - if _fm: - for _line in _fm.group(1).split("\n"): - if _line.strip().startswith("target-metric:"): - selected_target_metric = float(_line.split(":", 1)[1].strip()) - break - except (OSError, ValueError, TypeError): - pass - print(f"FORCED: running program '{forced_program}' (manual dispatch)") - elif due: - # Normal scheduling: pick the single most-overdue program - due.sort(key=lambda p: p["last_run"] or "") # None/empty sorts first (never run) - selected = due[0]["name"] - selected_file = due[0]["file"] - selected_target_metric = due[0].get("target_metric") - deferred = [p["name"] for p in due[1:]] - # Check if the selected program is issue-based - if selected in issue_programs: - selected_issue = issue_programs[selected]["issue_number"] - - result = { - "selected": selected, - "selected_file": selected_file, - "selected_issue": selected_issue, - "selected_target_metric": selected_target_metric, - "issue_programs": {name: info["issue_number"] for name, info in issue_programs.items()}, - "deferred": deferred, - "skipped": skipped, - "unconfigured": unconfigured, - "no_programs": False, - } - - os.makedirs("/tmp/gh-aw", exist_ok=True) - with open("/tmp/gh-aw/autoloop.json", "w") as f: - json.dump(result, f, indent=2) - - print("=== Autoloop Program Check ===") - print(f"Selected program: {selected or '(none)'} ({selected_file or 'n/a'})") - print(f"Deferred (next run): {deferred or '(none)'}") - print(f"Programs skipped: {[s['name'] for s in skipped] or '(none)'}") - print(f"Programs unconfigured: {unconfigured or '(none)'}") - - if not selected and not unconfigured: - print("\nNo programs due this run. Exiting early.") - sys.exit(1) # Non-zero exit skips the agent step - PYEOF + python3 .github/workflows/scripts/autoloop_scheduler.py source: githubnext/autoloop engine: copilot diff --git a/.github/workflows/scripts/autoloop_scheduler.py b/.github/workflows/scripts/autoloop_scheduler.py new file mode 100644 index 0000000..5066afe --- /dev/null +++ b/.github/workflows/scripts/autoloop_scheduler.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +"""Autoloop scheduler. + +Decides which Autoloop program (if any) is due for an iteration. Reads +program definitions from ``.autoloop/programs/`` (directory- and bare- +markdown-based) and from open GitHub issues labelled ``autoloop-program``, +combines them with persisted per-program scheduling state from the +``memory/autoloop`` repo-memory branch, and writes the selection to +``/tmp/gh-aw/autoloop.json`` for the agent step to consume. + +Side effects: + * May bootstrap ``.autoloop/programs/example.md`` on first run. + * May materialise issue-based program bodies under + ``/tmp/gh-aw/issue-programs/``. + * Always writes ``/tmp/gh-aw/autoloop.json``. + +Exit codes: + 0 - a program was selected, or there are unconfigured programs to + report on (the agent step should run). + 1 - nothing to do this run (no due programs, no unconfigured + programs); the workflow should skip the agent step. + +Environment variables: + GITHUB_TOKEN - token used to query the issues API. + GITHUB_REPOSITORY - ``owner/repo`` slug. + AUTOLOOP_PROGRAM - optional program name to force (bypasses + scheduling, but unconfigured programs are still + rejected). + +This file is the standalone counterpart of the inline scheduler that +previously lived in ``workflows/autoloop.md``. Extracting it keeps the +compiled ``run:`` step small (avoiding GitHub Actions' inline-expression +size limit) and makes the logic unit-testable from ``tests/``. +""" + +from __future__ import annotations + +import glob +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone + +PROGRAMS_DIR = ".autoloop/programs" +TEMPLATE_FILE = os.path.join(PROGRAMS_DIR, "example.md") + +# Repo-memory files are cloned to /tmp/gh-aw/repo-memory/{id}/ where {id} +# is derived from the branch-name configured in the tools section +# (memory/autoloop -> autoloop). +REPO_MEMORY_DIR = "/tmp/gh-aw/repo-memory/autoloop" + +ISSUE_PROGRAMS_DIR = "/tmp/gh-aw/issue-programs" +OUTPUT_DIR = "/tmp/gh-aw" +OUTPUT_FILE = os.path.join(OUTPUT_DIR, "autoloop.json") + + +# --------------------------------------------------------------------------- +# Pure helpers (unit-tested directly) +# --------------------------------------------------------------------------- + + +def parse_machine_state(content): + """Parse the ⚙️ Machine State table from a state file. Returns a dict.""" + state = {} + m = re.search(r"## ⚙️ Machine State.*?\n(.*?)(?=\n## |\Z)", content, re.DOTALL) + if not m: + return state + section = m.group(0) + for row in re.finditer(r"\|\s*(.+?)\s*\|\s*(.+?)\s*\|", section): + raw_key = row.group(1).strip() + raw_val = row.group(2).strip() + if raw_key.lower() in ("field", "---", ":---", ":---:", "---:"): + continue + key = raw_key.lower().replace(" ", "_") + val = None if raw_val in ("—", "-", "") else raw_val + state[key] = val + # Coerce types + for int_field in ("iteration_count", "consecutive_errors"): + if int_field in state: + try: + state[int_field] = int(state[int_field]) + except (ValueError, TypeError): + state[int_field] = 0 + if "paused" in state: + state["paused"] = str(state.get("paused", "")).lower() == "true" + if "completed" in state: + state["completed"] = str(state.get("completed", "")).lower() == "true" + # recent_statuses: stored as comma-separated words (e.g. "accepted, rejected, error") + rs_raw = state.get("recent_statuses") or "" + if rs_raw: + state["recent_statuses"] = [s.strip().lower() for s in rs_raw.split(",") if s.strip()] + else: + state["recent_statuses"] = [] + return state + + +def parse_schedule(s): + """Schedule string to a ``timedelta``; returns ``None`` for invalid input.""" + s = s.strip().lower() + m = re.match(r"every\s+(\d+)\s*h", s) + if m: + return timedelta(hours=int(m.group(1))) + m = re.match(r"every\s+(\d+)\s*m", s) + if m: + return timedelta(minutes=int(m.group(1))) + if s == "daily": + return timedelta(hours=24) + if s == "weekly": + return timedelta(days=7) + return None + + +def get_program_name(pf): + """Extract program name from a program file path. + + Directory-based: ``.autoloop/programs//program.md`` -> ```` + Bare markdown: ``.autoloop/programs/.md`` -> ```` + Issue-based: ``/tmp/gh-aw/issue-programs/.md`` -> ```` + """ + if pf.endswith("/program.md"): + return os.path.basename(os.path.dirname(pf)) + return os.path.splitext(os.path.basename(pf))[0] + + +def slugify_issue_title(title, number=None): + """Slugify a GitHub issue title into a program name.""" + slug = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-") + slug = re.sub(r"-+", "-", slug) # collapse consecutive hyphens + if not slug: + slug = "issue-{}".format(number) if number is not None else "issue" + return slug + + +def parse_link_header(header): + """Parse the GitHub API ``Link`` header and return the ``rel="next"`` URL.""" + if not header: + return None + for part in header.split(","): + section = part.strip() + m = re.match(r'^<([^>]+)>;\s*rel="next"$', section) + if m: + return m.group(1) + return None + + +def parse_program_frontmatter(content): + """Parse optional YAML frontmatter for ``schedule`` and ``target-metric``. + + Returns ``(schedule_delta, target_metric, target_metric_invalid_value)``. + The third element is the raw string of an invalid ``target-metric`` value + (so the caller can warn), or ``None`` when the value parsed cleanly or was + absent. + """ + # Strip leading HTML comments before checking (issue-based programs may have them). + content_stripped = re.sub(r"^(\s*\s*\n)*", "", content, flags=re.DOTALL) + schedule_delta = None + target_metric = None + target_metric_invalid = None + fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content_stripped, re.DOTALL) + if not fm_match: + return schedule_delta, target_metric, target_metric_invalid + for line in fm_match.group(1).split("\n"): + if line.strip().startswith("schedule:"): + schedule_str = line.split(":", 1)[1].strip() + schedule_delta = parse_schedule(schedule_str) + if line.strip().startswith("target-metric:"): + raw = line.split(":", 1)[1].strip() + try: + target_metric = float(raw) + except (ValueError, TypeError): + target_metric_invalid = raw + return schedule_delta, target_metric, target_metric_invalid + + +def is_unconfigured(content): + """Return True if a program file still contains the unconfigured sentinel + or any TODO/REPLACE placeholder.""" + if "" in content: + return True + if re.search(r"\bTODO\b|\bREPLACE", content): + return True + return False + + +def check_skip_conditions(state): + """Return ``(should_skip, reason)`` based on the program state.""" + if str(state.get("completed", "")).lower() == "true" or state.get("completed") is True: + return True, "completed: target metric reached" + if state.get("paused"): + return True, "paused: {}".format(state.get("pause_reason", "unknown")) + recent = state.get("recent_statuses", [])[-5:] + if len(recent) >= 5 and all(s == "rejected" for s in recent): + return True, "plateau: 5 consecutive rejections" + return False, None + + +# --------------------------------------------------------------------------- +# I/O helpers +# --------------------------------------------------------------------------- + + +def read_program_state(program_name, repo_memory_dir=REPO_MEMORY_DIR): + """Read scheduling state from the repo-memory state file (or ``{}``).""" + state_file = os.path.join(repo_memory_dir, "{}.md".format(program_name)) + if not os.path.isfile(state_file): + print(" {}: no state file found (first run)".format(program_name)) + return {} + with open(state_file, encoding="utf-8") as f: + content = f.read() + return parse_machine_state(content) + + +def _bootstrap_template_if_missing(): + """Create ``.autoloop/programs/example.md`` if the directory is missing.""" + if os.path.isdir(PROGRAMS_DIR): + return + os.makedirs(PROGRAMS_DIR, exist_ok=True) + bt = chr(96) # backtick — keep gh-aw compiler happy if this ever gets inlined + template = "\n".join([ + "", + "", + "", + "", + "# Autoloop Program", + "", + "", + "", + "## Goal", + "", + "", + "", + "REPLACE THIS with your optimization goal.", + "", + "## Target", + "", + "", + "", + "Only modify these files:", + "- {bt}REPLACE_WITH_FILE{bt} -- (describe what this file does)".format(bt=bt), + "", + "Do NOT modify:", + "- (list files that must not be touched)", + "", + "## Evaluation", + "", + "", + "", + "{bt}{bt}{bt}bash".format(bt=bt), + "REPLACE_WITH_YOUR_EVALUATION_COMMAND", + "{bt}{bt}{bt}".format(bt=bt), + "", + "The metric is {bt}REPLACE_WITH_METRIC_NAME{bt}. **Lower/Higher is better.** (pick one)".format(bt=bt), + "", + ]) + with open(TEMPLATE_FILE, "w") as f: + f.write(template) + # Leave the template unstaged — the agent will create a draft PR with it + print("BOOTSTRAPPED: created {} locally (agent will create a draft PR)".format(TEMPLATE_FILE)) + + +def _scan_directory_programs(): + """Return paths of directory-based programs under ``PROGRAMS_DIR``.""" + out = [] + if not os.path.isdir(PROGRAMS_DIR): + return out + for entry in sorted(os.listdir(PROGRAMS_DIR)): + prog_dir = os.path.join(PROGRAMS_DIR, entry) + if os.path.isdir(prog_dir): + prog_file = os.path.join(prog_dir, "program.md") + if os.path.isfile(prog_file): + out.append(prog_file) + return out + + +def _scan_bare_programs(): + """Return paths of bare-markdown programs under ``PROGRAMS_DIR``.""" + return sorted(glob.glob(os.path.join(PROGRAMS_DIR, "*.md"))) + + +def _fetch_issue_programs(repo, github_token): + """Fetch open issues with the ``autoloop-program`` label and write their + bodies to ``ISSUE_PROGRAMS_DIR``. Returns ``(program_files, issue_programs)``. + + Errors are swallowed (with a warning) so a transient API failure doesn't + block the run for non-issue-based programs. + """ + program_files = [] + issue_programs = {} + os.makedirs(ISSUE_PROGRAMS_DIR, exist_ok=True) + next_url = ( + "https://api.github.com/repos/{}/issues" + "?labels=autoloop-program&state=open&per_page=100".format(repo) + ) + headers = { + "Authorization": "token {}".format(github_token), + "Accept": "application/vnd.github.v3+json", + } + issues = [] + try: + while next_url: + req = urllib.request.Request(next_url, headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: + page = json.loads(resp.read().decode()) + link_header = resp.headers.get("link") or resp.headers.get("Link") + issues.extend(page) + next_url = parse_link_header(link_header) + for issue in issues: + if issue.get("pull_request"): + continue # skip PRs + body = issue.get("body") or "" + title = issue.get("title") or "" + number = issue["number"] + slug = slugify_issue_title(title, number) + if slug in issue_programs: + print( + " Warning: slug '{}' (issue #{}) collides with issue #{}, " + "appending issue number".format( + slug, number, issue_programs[slug]["issue_number"] + ) + ) + slug = "{}-{}".format(slug, number) + issue_file = os.path.join(ISSUE_PROGRAMS_DIR, "{}.md".format(slug)) + with open(issue_file, "w") as f: + f.write(body) + program_files.append(issue_file) + issue_programs[slug] = {"issue_number": number, "file": issue_file, "title": title} + print(" Found issue-based program: '{}' (issue #{})".format(slug, number)) + except Exception as e: # noqa: BLE001 -- best-effort; logged below + print(" Warning: could not fetch issue-based programs: {}".format(e)) + return program_files, issue_programs + + +def _parse_target_metric_from_file(path): + """Re-parse a program file to extract its ``target-metric``, if any.""" + try: + with open(path) as f: + _, target_metric, _ = parse_program_frontmatter(f.read()) + return target_metric + except (OSError, ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Selection +# --------------------------------------------------------------------------- + + +def select_program(due, forced_program=None, all_programs=None, unconfigured=None, issue_programs=None): + """Pick the program to run. + + Returns ``(selected, selected_file, selected_issue, selected_target_metric, + deferred, error)``. ``error`` is a string describing why a forced selection + failed (and the caller should ``sys.exit(1)``); otherwise it is ``None``. + """ + all_programs = all_programs or {} + unconfigured = unconfigured or [] + issue_programs = issue_programs or {} + if forced_program: + if forced_program not in all_programs: + return ( + None, None, None, None, [], + "requested program '{}' not found. Available programs: {}".format( + forced_program, list(all_programs.keys()) + ), + ) + if forced_program in unconfigured: + return ( + None, None, None, None, [], + "requested program '{}' is unconfigured (has placeholders).".format( + forced_program + ), + ) + selected = forced_program + selected_file = all_programs[forced_program] + deferred = [p["name"] for p in due if p["name"] != forced_program] + selected_issue = ( + issue_programs[selected]["issue_number"] if selected in issue_programs else None + ) + selected_target_metric = None + for p in due: + if p["name"] == forced_program: + selected_target_metric = p.get("target_metric") + break + if selected_target_metric is None: + selected_target_metric = _parse_target_metric_from_file(selected_file) + return selected, selected_file, selected_issue, selected_target_metric, deferred, None + + if due: + # Normal scheduling: pick the single most-overdue program. + # ``last_run`` of None/empty sorts first (never run). + due_sorted = sorted(due, key=lambda p: p["last_run"] or "") + selected = due_sorted[0]["name"] + selected_file = due_sorted[0]["file"] + selected_target_metric = due_sorted[0].get("target_metric") + deferred = [p["name"] for p in due_sorted[1:]] + selected_issue = ( + issue_programs[selected]["issue_number"] if selected in issue_programs else None + ) + return selected, selected_file, selected_issue, selected_target_metric, deferred, None + + return None, None, None, None, [], None + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + github_token = os.environ.get("GITHUB_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + forced_program = os.environ.get("AUTOLOOP_PROGRAM", "").strip() + + _bootstrap_template_if_missing() + + # Find all program files from all locations: + # 1. Directory-based programs: .autoloop/programs//program.md (preferred) + # 2. Bare markdown programs: .autoloop/programs/.md (simple) + # 3. Issue-based programs: GitHub issues with the 'autoloop-program' label + program_files = [] + program_files.extend(_scan_directory_programs()) + program_files.extend(_scan_bare_programs()) + issue_files, issue_programs = _fetch_issue_programs(repo, github_token) + program_files.extend(issue_files) + + if not program_files: + # Fallback to single-file locations + for path in [".autoloop/program.md", "program.md"]: + if os.path.isfile(path): + program_files = [path] + break + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + if not program_files: + print("NO_PROGRAMS_FOUND") + with open(OUTPUT_FILE, "w") as f: + json.dump( + {"due": [], "skipped": [], "unconfigured": [], "no_programs": True}, f + ) + sys.exit(0) + + now = datetime.now(timezone.utc) + due = [] + skipped = [] + unconfigured = [] + all_programs = {} # name -> file path + + for pf in program_files: + name = get_program_name(pf) + all_programs[name] = pf + with open(pf) as f: + content = f.read() + + if is_unconfigured(content): + unconfigured.append(name) + continue + + schedule_delta, target_metric, invalid_target = parse_program_frontmatter(content) + if invalid_target is not None: + print(" Warning: {} has invalid target-metric value: {}".format(name, invalid_target)) + + # Read state from repo-memory + state = read_program_state(name) + if state: + print( + " {}: last_run={}, iteration_count={}".format( + name, state.get("last_run"), state.get("iteration_count") + ) + ) + else: + print(" {}: no state found (first run)".format(name)) + + last_run = None + lr = state.get("last_run") + if lr: + try: + last_run = datetime.fromisoformat(lr.replace("Z", "+00:00")) + except ValueError: + pass + + should_skip, reason = check_skip_conditions(state) + if should_skip: + skipped.append({"name": name, "reason": reason}) + continue + + # Check if due based on per-program schedule + if schedule_delta and last_run and now - last_run < schedule_delta: + skipped.append( + { + "name": name, + "reason": "not due yet", + "next_due": (last_run + schedule_delta).isoformat(), + } + ) + continue + + due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric}) + + selected, selected_file, selected_issue, selected_target_metric, deferred, error = ( + select_program(due, forced_program, all_programs, unconfigured, issue_programs) + ) + + if error: + print("ERROR: {}".format(error)) + sys.exit(1) + + if forced_program and selected: + print("FORCED: running program '{}' (manual dispatch)".format(forced_program)) + + result = { + "selected": selected, + "selected_file": selected_file, + "selected_issue": selected_issue, + "selected_target_metric": selected_target_metric, + "issue_programs": { + name: info["issue_number"] for name, info in issue_programs.items() + }, + "deferred": deferred, + "skipped": skipped, + "unconfigured": unconfigured, + "no_programs": False, + } + + with open(OUTPUT_FILE, "w") as f: + json.dump(result, f, indent=2) + + print("=== Autoloop Program Check ===") + print("Selected program: {} ({})".format(selected or "(none)", selected_file or "n/a")) + print("Deferred (next run): {}".format(deferred or "(none)")) + print("Programs skipped: {}".format([s["name"] for s in skipped] or "(none)")) + print("Programs unconfigured: {}".format(unconfigured or "(none)")) + + if not selected and not unconfigured: + print("\nNo programs due this run. Exiting early.") + sys.exit(1) # Non-zero exit skips the agent step + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21782d1..8241b7d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,5 +13,5 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pytest + - run: pip install pytest pyyaml - run: pytest tests/ -v diff --git a/AGENTS.md b/AGENTS.md index 80b2056..f3a517f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,10 @@ autoloop/ ├── workflows/ ← Agentic Workflow definitions │ ├── autoloop.md ← main autoloop workflow (compiled by gh-aw) │ ├── sync-branches.md ← syncs default branch into autoloop/* branches -│ └── shared/ ← shared workflow fragments -│ └── reporting.md +│ ├── shared/ ← shared workflow fragments +│ │ └── reporting.md +│ └── scripts/ ← standalone scripts invoked from steps +│ └── autoloop_scheduler.py ← scheduler (see workflows/autoloop.md) ├── .autoloop/ │ └── programs/ ← research programs (directory-based) │ ├── function_minimization/ @@ -134,6 +136,7 @@ To deploy the workflow to a repository: 1. Copy `workflows/autoloop.md` to `.github/workflows/autoloop.md` in the target repo 2. Copy `workflows/sync-branches.md` to `.github/workflows/sync-branches.md` in the target repo 3. Copy `workflows/shared/` to `.github/workflows/shared/` in the target repo -4. Run `gh aw compile autoloop` and `gh aw compile sync-branches` to generate the lock files -5. Copy program directories to `.autoloop/programs/` in the target repo -6. Commit and push +4. Copy `workflows/scripts/` to `.github/workflows/scripts/` in the target repo +5. Run `gh aw compile autoloop` and `gh aw compile sync-branches` to generate the lock files +6. Copy program directories to `.autoloop/programs/` in the target repo +7. Commit and push diff --git a/tests/conftest.py b/tests/conftest.py index e23352c..0279751 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,117 +1,36 @@ -""" -Extract scheduling functions directly from the workflow pre-step heredoc. - -Instead of duplicating the workflow's JavaScript code in a separate module, we parse -workflows/autoloop.md, extract the JavaScript heredoc, write the function definitions -to a temp CommonJS module, and call them via Node.js subprocess. +"""Test fixtures for the standalone Autoloop scheduler. -This ensures tests always run against the actual workflow code. +The scheduler logic lives in ``workflows/scripts/autoloop_scheduler.py`` and is +also distributed at ``.github/workflows/scripts/autoloop_scheduler.py`` (the +dogfooded deploy copy). Tests import the source module directly via importlib. """ -import json +import importlib.util import os -import re -import subprocess -import tempfile -from datetime import timedelta - -WORKFLOW_PATH = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md") - -# Path to the extracted JS module -_JS_MODULE_PATH = os.path.join(tempfile.gettempdir(), "autoloop_test_functions.cjs") - - -def _load_workflow_functions(): - """Parse workflows/autoloop.md and extract JS function defs from the pre-step.""" - with open(WORKFLOW_PATH) as f: - content = f.read() - - # Extract the JavaScript heredoc between JSEOF markers - m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL) - assert m, "Could not find JSEOF heredoc in workflows/autoloop.md" - source = m.group(1) - - # Extract function definitions: everything up to the main() async function. - # Functions are defined before 'async function main()' - lines = source.split("\n") - func_lines = [] - for line in lines: - if line.strip().startswith("async function main"): - break - func_lines.append(line) - - func_source = "\n".join(func_lines) - - # Write to a temp .cjs file with module.exports - with open(_JS_MODULE_PATH, "w") as f: - f.write(func_source) - f.write( - "\n\nmodule.exports = " - "{ parseMachineState, parseSchedule, getProgramName, readProgramState, parseLinkHeader };\n" - ) - - return True - - -def _call_js(func_name, *args): - """Call a JS function from the extracted workflow module and return the result.""" - args_json = json.dumps(list(args)) - escaped_path = json.dumps(_JS_MODULE_PATH) - script = ( - "const m = require(" + escaped_path + ");\n" - "const result = m." + func_name + "(..." + args_json + ");\n" - "process.stdout.write(JSON.stringify(result === undefined ? null : result));\n" - ) - result = subprocess.run( - ["node", "-e", script], - capture_output=True, - text=True, - timeout=10, +import sys + +# Path to the standalone scheduler script (source-of-truth lives in workflows/). +SCHEDULER_PATH = os.path.normpath( + os.path.join( + os.path.dirname(__file__), + "..", + "workflows", + "scripts", + "autoloop_scheduler.py", ) - if result.returncode != 0: - raise RuntimeError("Node.js error calling " + func_name + ": " + result.stderr) - if not result.stdout.strip(): - return None - return json.loads(result.stdout) - - -# Initialize at import time -_load_workflow_functions() - - -def _parse_schedule_wrapper(s): - """Python wrapper for JS parseSchedule. Converts milliseconds to timedelta.""" - ms = _call_js("parseSchedule", s) - if ms is None: - return None - return timedelta(milliseconds=ms) +) - -def _parse_machine_state_wrapper(content): - """Python wrapper for JS parseMachineState.""" - return _call_js("parseMachineState", content) - - -def _get_program_name_wrapper(pf): - """Python wrapper for JS getProgramName.""" - return _call_js("getProgramName", pf) +_spec = importlib.util.spec_from_file_location("autoloop_scheduler", SCHEDULER_PATH) +autoloop_scheduler = importlib.util.module_from_spec(_spec) +sys.modules["autoloop_scheduler"] = autoloop_scheduler +_spec.loader.exec_module(autoloop_scheduler) +# Backwards-compatible function map (mirrors the previous JS-extracting conftest). _funcs = { - "parse_schedule": _parse_schedule_wrapper, - "parse_machine_state": _parse_machine_state_wrapper, - "get_program_name": _get_program_name_wrapper, - "read_program_state": lambda name: _call_js("readProgramState", name), - "parse_link_header": lambda header: _call_js("parseLinkHeader", header), + "parse_schedule": autoloop_scheduler.parse_schedule, + "parse_machine_state": autoloop_scheduler.parse_machine_state, + "get_program_name": autoloop_scheduler.get_program_name, + "read_program_state": autoloop_scheduler.read_program_state, + "parse_link_header": autoloop_scheduler.parse_link_header, } - - -def _extract_inline_pattern(name): - """Extract the JavaScript heredoc source from the workflow. - - This is a helper for inspecting the full inline source if needed. - """ - with open(WORKFLOW_PATH) as f: - content = f.read() - m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL) - return m.group(1) if m else "" diff --git a/tests/test_scheduler_e2e.py b/tests/test_scheduler_e2e.py new file mode 100644 index 0000000..c9dc5a8 --- /dev/null +++ b/tests/test_scheduler_e2e.py @@ -0,0 +1,259 @@ +"""End-to-end fixture tests for the standalone Autoloop scheduler. + +These tests run ``workflows/scripts/autoloop_scheduler.py`` as a subprocess in +isolated temp directories and validate the resulting ``autoloop.json``. They +cover the scenarios called out in the extraction issue: + +* most-overdue selection (``last_run`` tie-break) +* missing state file → first run +* ``paused: true`` → skipped with reason +* ``completed: true`` → skipped +* ``AUTOLOOP_PROGRAM=`` → forced selection bypasses scheduling +* No programs found → ``no_programs: true`` + +The scheduler talks to the GitHub issues API; tests point ``GITHUB_REPOSITORY`` +at a non-resolvable host so the request fails fast, falling back to the +filesystem-discovered programs only (the script logs a warning and continues — +the same behaviour exercised in the workflow when issues are absent). +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap + +import pytest + +from conftest import SCHEDULER_PATH + +PROGRAM_TEMPLATE = textwrap.dedent("""\ + --- + schedule: every 6h + --- + + # {name} + + ## Goal + Optimize {name}. + + ## Target + - file.py + + ## Evaluation + ```bash + python eval.py + ``` + + The metric is `score`. Higher is better. +""") + + +def _state_file(name, *, last_run=None, paused=False, completed=False, pause_reason=None): + """Render a minimal repo-memory state file for a program.""" + rows = [ + ("Last Run", last_run if last_run else "—"), + ("Iteration Count", "0"), + ("Best Metric", "—"), + ("Target Metric", "—"), + ("Paused", "true" if paused else "false"), + ("Pause Reason", pause_reason or "—"), + ("Completed", "true" if completed else "false"), + ("Completed Reason", "—"), + ("Consecutive Errors", "0"), + ("Recent Statuses", "—"), + ] + body = "\n".join("| {} | {} |".format(k, v) for k, v in rows) + return textwrap.dedent("""\ + # Autoloop: {name} + + ## ⚙️ Machine State + + | Field | Value | + |-------|-------| + {body} + """).format(name=name, body=body) + + +def _run_scheduler(workdir, *, forced=None, repo="bogus.invalid/bogus"): + """Run the scheduler in ``workdir`` and return ``(returncode, autoloop_json)``. + + ``GITHUB_REPOSITORY`` defaults to a bogus DNS name so the issues fetch fails + instantly (DNS lookup error → caught, scheduler continues with filesystem + programs only). ``HOME`` is also rewritten so any state under ``/tmp/gh-aw`` + is owned by the test. + """ + env = os.environ.copy() + env["GITHUB_TOKEN"] = "dummy" + env["GITHUB_REPOSITORY"] = repo + if forced is not None: + env["AUTOLOOP_PROGRAM"] = forced + else: + env.pop("AUTOLOOP_PROGRAM", None) + + # The scheduler always writes /tmp/gh-aw/autoloop.json; isolate via TMPDIR + # so concurrent tests don't clobber each other. + tmproot = os.path.join(workdir, "_tmp") + os.makedirs(tmproot, exist_ok=True) + env["TMPDIR"] = tmproot + + # The scheduler always writes /tmp/gh-aw/autoloop.json. We can't redirect + # this via env vars without changing the script's contract, so tests share + # that path and clean up the previous run's output before invoking again. + out_path = "/tmp/gh-aw/autoloop.json" + if os.path.exists(out_path): + os.remove(out_path) + + proc = subprocess.run( + [sys.executable, SCHEDULER_PATH], + cwd=workdir, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + autoloop = None + if os.path.exists(out_path): + with open(out_path) as f: + autoloop = json.load(f) + return proc, autoloop + + +@pytest.fixture +def workdir(tmp_path, monkeypatch): + """Return an isolated workdir with ``.autoloop/programs/`` ready and + a fresh repo-memory directory the scheduler can read.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".autoloop" / "programs").mkdir(parents=True) + # The scheduler reads state from /tmp/gh-aw/repo-memory/autoloop. Clean it + # so each test starts from a known empty slate, then re-populate per-test. + repo_mem = "/tmp/gh-aw/repo-memory/autoloop" + if os.path.isdir(repo_mem): + shutil.rmtree(repo_mem) + os.makedirs(repo_mem, exist_ok=True) + return tmp_path + + +def _write_program(workdir, name, body=None): + p = workdir / ".autoloop" / "programs" / "{}.md".format(name) + p.write_text(body if body is not None else PROGRAM_TEMPLATE.format(name=name)) + return p + + +def _write_state(name, **kwargs): + repo_mem = "/tmp/gh-aw/repo-memory/autoloop" + os.makedirs(repo_mem, exist_ok=True) + with open(os.path.join(repo_mem, "{}.md".format(name)), "w") as f: + f.write(_state_file(name, **kwargs)) + + +# --------------------------------------------------------------------------- +# Scenario coverage +# --------------------------------------------------------------------------- + + +class TestSchedulerEndToEnd: + def test_picks_more_overdue(self, workdir): + """Two programs with different ``last_run`` → the older one is selected.""" + _write_program(workdir, "old") + _write_program(workdir, "fresh") + _write_state("old", last_run="2025-01-01T00:00:00Z") + _write_state("fresh", last_run="2025-01-15T00:00:00Z") + + proc, out = _run_scheduler(str(workdir)) + assert proc.returncode == 0, proc.stderr + assert out["selected"] == "old" + assert out["deferred"] == ["fresh"] + + def test_never_run_beats_recently_run(self, workdir): + """A never-run program is always more overdue than one with state.""" + _write_program(workdir, "veteran") + _write_program(workdir, "rookie") + _write_state("veteran", last_run="2025-01-15T00:00:00Z") + # No state file for "rookie" → first run + + proc, out = _run_scheduler(str(workdir)) + assert proc.returncode == 0, proc.stderr + assert out["selected"] == "rookie" + + def test_missing_state_file_treated_as_first_run(self, workdir): + """A single program with no state file is selected and treated as first run.""" + _write_program(workdir, "lonely") + proc, out = _run_scheduler(str(workdir)) + assert proc.returncode == 0, proc.stderr + assert out["selected"] == "lonely" + assert "no state file found (first run)" in proc.stdout + + def test_paused_program_is_skipped(self, workdir): + """``paused: true`` puts the program in ``skipped`` with a paused reason.""" + _write_program(workdir, "snoozer") + _write_state("snoozer", paused=True, pause_reason="manual") + + proc, out = _run_scheduler(str(workdir)) + # Only one program and it's paused → nothing due → exit 1 + assert proc.returncode == 1 + names = [s["name"] for s in out["skipped"]] + assert "snoozer" in names + reason = next(s["reason"] for s in out["skipped"] if s["name"] == "snoozer") + assert reason.startswith("paused:") + assert "manual" in reason + + def test_completed_program_is_skipped(self, workdir): + """``completed: true`` puts the program in ``skipped``.""" + _write_program(workdir, "graduated") + _write_state("graduated", completed=True) + + proc, out = _run_scheduler(str(workdir)) + assert proc.returncode == 1 + names = [s["name"] for s in out["skipped"]] + assert "graduated" in names + reason = next(s["reason"] for s in out["skipped"] if s["name"] == "graduated") + assert "completed" in reason + + def test_forced_program_bypasses_scheduling(self, workdir): + """``AUTOLOOP_PROGRAM`` forces the named program even if not most-overdue.""" + _write_program(workdir, "old") + _write_program(workdir, "fresh") + _write_state("old", last_run="2025-01-01T00:00:00Z") + _write_state("fresh", last_run="2025-01-15T00:00:00Z") + + # Without forcing, "old" wins; with forcing "fresh" wins. + proc, out = _run_scheduler(str(workdir), forced="fresh") + assert proc.returncode == 0, proc.stderr + assert out["selected"] == "fresh" + assert out["deferred"] == ["old"] + assert "FORCED: running program 'fresh'" in proc.stdout + + def test_forced_program_can_run_paused(self, workdir): + """Forcing a paused program bypasses the skip and selects it anyway.""" + _write_program(workdir, "snoozer") + _write_state("snoozer", paused=True, pause_reason="manual") + + proc, out = _run_scheduler(str(workdir), forced="snoozer") + assert proc.returncode == 0, proc.stderr + assert out["selected"] == "snoozer" + + def test_forced_program_unknown_errors(self, workdir): + """Forcing an unknown program exits non-zero with an error.""" + _write_program(workdir, "real") + proc, _ = _run_scheduler(str(workdir), forced="nonexistent") + assert proc.returncode == 1 + assert "not found" in proc.stdout + + def test_no_programs_found(self, workdir): + """Empty programs dir → ``no_programs: true``, exit 0 (workflow handles bootstrap).""" + # Remove the bootstrapped programs dir so the scheduler has nothing to + # discover after its bootstrap step (which only creates the dir if it's + # missing entirely). + shutil.rmtree(workdir / ".autoloop" / "programs") + proc, out = _run_scheduler(str(workdir)) + # The bootstrap recreates the dir + example template (which contains + # REPLACE placeholders → unconfigured), so there is one unconfigured + # program. Exit 0 because the workflow still wants to surface the + # template via the agent step. + assert proc.returncode == 0, proc.stderr + assert out["unconfigured"] == ["example"] + assert out["selected"] is None diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 4944c3c..17dfc40 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -1,133 +1,57 @@ -"""Tests for the scheduling pre-step in workflows/autoloop.md. +"""Tests for the standalone Autoloop scheduler. -Functions are extracted directly from the workflow JavaScript heredoc at import -time (see conftest.py) and called via Node.js subprocess — there is no separate -copy of the scheduling code. - -For inline logic (slugify, frontmatter parsing, skip conditions, etc.) that -isn't wrapped in a named function in the workflow, we write thin test helpers -that replicate the exact inline pattern. These are documented with the -workflow source patterns they correspond to. +The scheduler module is imported directly (see ``conftest.py``); functions are +exercised in-process. A few thin helpers below match the legacy 2-tuple/no-args +shapes used by the tests, while delegating to the shared scheduler module. """ import re from datetime import datetime, timezone, timedelta -from conftest import _funcs +from conftest import _funcs, autoloop_scheduler # --------------------------------------------------------------------------- -# Functions extracted from the workflow via AST (see conftest.py) +# Functions exposed by the scheduler module # --------------------------------------------------------------------------- parse_schedule = _funcs["parse_schedule"] parse_machine_state = _funcs["parse_machine_state"] get_program_name = _funcs["get_program_name"] parse_link_header = _funcs["parse_link_header"] +is_unconfigured = autoloop_scheduler.is_unconfigured +check_skip_conditions = autoloop_scheduler.check_skip_conditions +select_program = autoloop_scheduler.select_program # --------------------------------------------------------------------------- -# Thin helpers that replicate inline workflow patterns (not function defs). -# Each documents the workflow source lines it mirrors. +# Thin helpers preserving the legacy test-helper shapes. # --------------------------------------------------------------------------- def slugify_issue_title(title): - """Replicates the inline slug logic in the workflow's issue scanning section.""" - slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-') + """Slugify a title (the workflow's inline issue-scanning slug logic). + + The scheduler module's ``slugify_issue_title`` falls back to ``"issue"`` + when no number is provided and the title slugifies to empty; the original + inline workflow code only fell back when ``number`` was known. This helper + preserves the original behaviour by passing through an empty string. + """ + slug = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') slug = re.sub(r'-+', '-', slug) return slug def parse_frontmatter(content): - """Replicates the inline frontmatter parsing in the workflow's program scanning loop.""" - content_stripped = re.sub(r'^(\s*\s*\n)*', '', content, flags=re.DOTALL) - schedule_delta = None - target_metric = None - fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content_stripped, re.DOTALL) - if fm_match: - for line in fm_match.group(1).split("\n"): - if line.strip().startswith("schedule:"): - schedule_str = line.split(":", 1)[1].strip() - schedule_delta = parse_schedule(schedule_str) - if line.strip().startswith("target-metric:"): - try: - target_metric = float(line.split(":", 1)[1].strip()) - except (ValueError, TypeError): - pass + """Two-tuple wrapper over the scheduler's three-tuple frontmatter parser.""" + schedule_delta, target_metric, _ = autoloop_scheduler.parse_program_frontmatter(content) return schedule_delta, target_metric -def is_unconfigured(content): - """Replicates the inline unconfigured check in the workflow's program scanning loop.""" - if "" in content: - return True - if re.search(r'\bTODO\b|\bREPLACE', content): - return True - return False - - -def check_skip_conditions(state): - """Replicates the inline skip logic in the workflow's program scanning loop. - - Returns (should_skip, reason). - """ - # Line 348: completed check - if str(state.get("completed", "")).lower() == "true" or state.get("completed") is True: - return True, "completed: target metric reached" - # Line 353: paused check - if state.get("paused"): - return True, f"paused: {state.get('pause_reason', 'unknown')}" - # Lines 357-361: plateau check - recent = state.get("recent_statuses", [])[-5:] - if len(recent) >= 5 and all(s == "rejected" for s in recent): - return True, "plateau: 5 consecutive rejections" - return False, None - - def check_if_due(schedule_delta, last_run, now): - """Replicates the inline due check in the workflow's program scanning loop. - - Returns (is_due, next_due_iso). - """ + """Replicates the inline due check: ``(is_due, next_due_iso_or_None)``.""" if schedule_delta and last_run: if now - last_run < schedule_delta: return False, (last_run + schedule_delta).isoformat() return True, None -def select_program(due, forced_program=None, all_programs=None, unconfigured=None, issue_programs=None): - """Replicates the selection logic in the workflow's program selection section. - - Returns (selected, selected_file, selected_issue, selected_target_metric, deferred, error). - """ - all_programs = all_programs or {} - unconfigured = unconfigured or [] - issue_programs = issue_programs or {} - - if forced_program: - if forced_program not in all_programs: - return None, None, None, None, [], f"program '{forced_program}' not found" - if forced_program in unconfigured: - return None, None, None, None, [], f"program '{forced_program}' is unconfigured" - selected = forced_program - selected_file = all_programs[forced_program] - deferred = [p["name"] for p in due if p["name"] != forced_program] - selected_issue = issue_programs.get(selected) - selected_target_metric = None - for p in due: - if p["name"] == forced_program: - selected_target_metric = p.get("target_metric") - break - return selected, selected_file, selected_issue, selected_target_metric, deferred, None - elif due: - due.sort(key=lambda p: p["last_run"] or "") - selected = due[0]["name"] - selected_file = due[0]["file"] - selected_target_metric = due[0].get("target_metric") - deferred = [p["name"] for p in due[1:]] - selected_issue = issue_programs.get(selected) - return selected, selected_file, selected_issue, selected_target_metric, deferred, None - - return None, None, None, None, [], None - - # =========================================================================== # Tests # =========================================================================== @@ -617,7 +541,7 @@ def test_forced_program_unconfigured(self): def test_forced_issue_program(self): due = [] all_progs = {"my-issue": "/tmp/gh-aw/issue-programs/my-issue.md"} - issue_progs = {"my-issue": 42} + issue_progs = {"my-issue": {"issue_number": 42, "file": "/tmp/x", "title": "X"}} selected, file, issue, target, deferred, err = select_program( due, forced_program="my-issue", all_programs=all_progs, issue_programs=issue_progs ) @@ -628,7 +552,7 @@ def test_issue_program_selected_normally(self): due = [ {"name": "my-issue", "last_run": None, "file": "/tmp/my-issue.md", "target_metric": None}, ] - issue_progs = {"my-issue": 7} + issue_progs = {"my-issue": {"issue_number": 7, "file": "/tmp/x", "title": "X"}} selected, file, issue, target, deferred, err = select_program( due, issue_programs=issue_progs ) diff --git a/workflows/autoloop.md b/workflows/autoloop.md index 52c94a7..57c1511 100644 --- a/workflows/autoloop.md +++ b/workflows/autoloop.md @@ -109,469 +109,7 @@ steps: GITHUB_REPOSITORY: ${{ github.repository }} AUTOLOOP_PROGRAM: ${{ github.event.inputs.program }} run: | - node - << 'JSEOF' - const fs = require('fs'); - const path = require('path'); - - const programsDir = '.autoloop/programs'; - const autoloopDir = '.autoloop/programs'; - const templateFile = path.join(autoloopDir, 'example.md'); - - // Read program state from repo-memory (persistent git-backed storage) - const githubToken = process.env.GITHUB_TOKEN || ''; - const repo = process.env.GITHUB_REPOSITORY || ''; - const forcedProgram = (process.env.AUTOLOOP_PROGRAM || '').trim(); - - // Repo-memory files are cloned to /tmp/gh-aw/repo-memory/{id}/ where {id} - // is derived from the branch-name configured in the tools section (memory/autoloop -> autoloop) - const repoMemoryDir = '/tmp/gh-aw/repo-memory/autoloop'; - - function parseMachineState(content) { - const state = {}; - const sectionMatch = content.match(/## ⚙️ Machine State[^\n]*\n([\s\S]*?)(?=\n## |$)/); - if (!sectionMatch) return state; - const section = sectionMatch[0]; - const rowRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g; - let row; - while ((row = rowRegex.exec(section)) !== null) { - const rawKey = row[1].trim(); - const rawVal = row[2].trim(); - if (['field', '---', ':---', ':---:', '---:'].includes(rawKey.toLowerCase())) continue; - const key = rawKey.toLowerCase().replace(/ /g, '_'); - const val = ['\u2014', '-', ''].includes(rawVal) ? null : rawVal; // \u2014 = em dash - state[key] = val; - } - // Coerce types - for (const intField of ['iteration_count', 'consecutive_errors']) { - if (intField in state) { - const n = parseInt(state[intField], 10); - state[intField] = isNaN(n) ? 0 : n; - } - } - if ('paused' in state) { - state.paused = String(state.paused || '').toLowerCase() === 'true'; - } - if ('completed' in state) { - state.completed = String(state.completed || '').toLowerCase() === 'true'; - } - // recent_statuses: stored as comma-separated words (e.g. "accepted, rejected, error") - const rsRaw = state.recent_statuses || ''; - if (rsRaw) { - state.recent_statuses = rsRaw.split(',').map(s => s.trim().toLowerCase()).filter(s => s); - } else { - state.recent_statuses = []; - } - return state; - } - - function readProgramState(programName) { - const stateFile = path.join(repoMemoryDir, programName + '.md'); - try { - if (!fs.statSync(stateFile).isFile()) { - console.log(' ' + programName + ': no state file found (first run)'); - return {}; - } - } catch (e) { - console.log(' ' + programName + ': no state file found (first run)'); - return {}; - } - const content = fs.readFileSync(stateFile, 'utf-8'); - return parseMachineState(content); - } - - // Schedule string to milliseconds - function parseSchedule(s) { - s = s.trim().toLowerCase(); - let m = s.match(/^every\s+(\d+)\s*h/); - if (m) return parseInt(m[1], 10) * 3600 * 1000; - m = s.match(/^every\s+(\d+)\s*m/); - if (m) return parseInt(m[1], 10) * 60 * 1000; - if (s === 'daily') return 24 * 3600 * 1000; - if (s === 'weekly') return 7 * 24 * 3600 * 1000; - return null; - } - - function getProgramName(pf) { - // Extract program name from file path. - // Directory-based: .autoloop/programs//program.md -> - // Bare markdown: .autoloop/programs/.md -> - // Issue-based: /tmp/gh-aw/issue-programs/.md -> - if (pf.endsWith('/program.md')) { - return path.basename(path.dirname(pf)); - } else { - return path.parse(pf).name; - } - } - - // Parse the GitHub API Link header to extract the "next" page URL. - // Returns the URL string for the next page, or null if there is none. - function parseLinkHeader(header) { - if (!header) return null; - var parts = header.split(','); - for (var i = 0; i < parts.length; i++) { - var section = parts[i].trim(); - var m = section.match(/^<([^>]+)>;\s*rel="next"$/); - if (m) return m[1]; - } - return null; - } - - // Main execution - async function main() { - // Bootstrap: create autoloop programs directory and template if missing - if (!fs.existsSync(autoloopDir)) { - fs.mkdirSync(autoloopDir, { recursive: true }); - const bt = String.fromCharCode(96); // backtick -- avoid literal backticks that break gh-aw compiler - const template = [ - '', - '', - '', - '', - '# Autoloop Program', - '', - '', - '', - '## Goal', - '', - "", - '', - 'REPLACE THIS with your optimization goal.', - '', - '## Target', - '', - '', - '', - 'Only modify these files:', - '- ' + bt + 'REPLACE_WITH_FILE' + bt + ' -- (describe what this file does)', - '', - 'Do NOT modify:', - '- (list files that must not be touched)', - '', - '## Evaluation', - '', - '', - '', - bt + bt + bt + 'bash', - 'REPLACE_WITH_YOUR_EVALUATION_COMMAND', - bt + bt + bt, - '', - 'The metric is ' + bt + 'REPLACE_WITH_METRIC_NAME' + bt + '. **Lower/Higher is better.** (pick one)', - '', - ].join('\n'); - fs.writeFileSync(templateFile, template); - console.log('BOOTSTRAPPED: created ' + templateFile + ' locally (agent will create a draft PR)'); - } - - // Find all program files from all locations: - // 1. Directory-based programs: .autoloop/programs//program.md (preferred) - // 2. Bare markdown programs: .autoloop/programs/.md (simple) - // 3. Issue-based programs: GitHub issues with the 'autoloop-program' label - let programFiles = []; - const issuePrograms = {}; - - // Scan .autoloop/programs/ for directory-based programs - if (fs.existsSync(programsDir)) { - try { - if (fs.statSync(programsDir).isDirectory()) { - const entries = fs.readdirSync(programsDir).sort(); - for (const entry of entries) { - const progDir = path.join(programsDir, entry); - try { - if (fs.statSync(progDir).isDirectory()) { - const progFile = path.join(progDir, 'program.md'); - try { - if (fs.statSync(progFile).isFile()) { - programFiles.push(progFile); - } - } catch (e) { /* file doesn't exist */ } - } - } catch (e) { /* stat failed */ } - } - } - } catch (e) { /* stat failed */ } - } - - // Scan .autoloop/programs/ for bare markdown programs - if (fs.existsSync(autoloopDir)) { - try { - if (fs.statSync(autoloopDir).isDirectory()) { - const barePrograms = fs.readdirSync(autoloopDir) - .filter(f => f.endsWith('.md')) - .sort() - .map(f => path.join(autoloopDir, f)); - for (const pf of barePrograms) { - programFiles.push(pf); - } - } - } catch (e) { /* stat failed */ } - } - - // Scan GitHub issues with the 'autoloop-program' label (paginated) - const issueProgramsDir = '/tmp/gh-aw/issue-programs'; - fs.mkdirSync(issueProgramsDir, { recursive: true }); - try { - let nextUrl = 'https://api.github.com/repos/' + repo + '/issues?labels=autoloop-program&state=open&per_page=100'; - const issues = []; - while (nextUrl) { - const response = await fetch(nextUrl, { - headers: { - 'Authorization': 'token ' + githubToken, - 'Accept': 'application/vnd.github.v3+json', - }, - }); - const page = await response.json(); - issues.push(...page); - nextUrl = parseLinkHeader(response.headers.get('link')); - } - for (const issue of issues) { - if (issue.pull_request) continue; // skip PRs - const body = issue.body || ''; - const title = issue.title || ''; - const number = issue.number; - // Derive program name from issue title: slugify to lowercase with hyphens - let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); - slug = slug.replace(/-+/g, '-'); // collapse consecutive hyphens - if (!slug) slug = 'issue-' + number; - // Avoid slug collisions: if another issue already claimed this slug, append issue number - if (slug in issuePrograms) { - console.log(" Warning: slug '" + slug + "' (issue #" + number + ") collides with issue #" + issuePrograms[slug].issue_number + ", appending issue number"); - slug = slug + '-' + number; - } - // Write issue body to a temp file so the scheduling loop can process it - const issueFile = path.join(issueProgramsDir, slug + '.md'); - fs.writeFileSync(issueFile, body); - programFiles.push(issueFile); - issuePrograms[slug] = { issue_number: number, file: issueFile, title: title }; - console.log(" Found issue-based program: '" + slug + "' (issue #" + number + ")"); - } - } catch (e) { - console.log(' Warning: could not fetch issue-based programs: ' + e.message); - } - - if (programFiles.length === 0) { - // Fallback to single-file locations - for (const p of ['.autoloop/program.md', 'program.md']) { - try { - if (fs.statSync(p).isFile()) { - programFiles = [p]; - break; - } - } catch (e) { /* file doesn't exist */ } - } - } - - if (programFiles.length === 0) { - console.log('NO_PROGRAMS_FOUND'); - fs.mkdirSync('/tmp/gh-aw', { recursive: true }); - fs.writeFileSync('/tmp/gh-aw/autoloop.json', JSON.stringify( - { due: [], skipped: [], unconfigured: [], no_programs: true } - )); - process.exit(0); - } - - fs.mkdirSync('/tmp/gh-aw', { recursive: true }); - const now = new Date(); - const due = []; - const skipped = []; - const unconfigured = []; - const allPrograms = {}; - - for (const pf of programFiles) { - const name = getProgramName(pf); - allPrograms[name] = pf; - const content = fs.readFileSync(pf, 'utf-8'); - - // Check sentinel (skip for issue-based programs which use AUTOLOOP:ISSUE-PROGRAM) - if (content.includes('')) { - unconfigured.push(name); - continue; - } - - // Check for TODO/REPLACE placeholders - if (/\bTODO\b|\bREPLACE/.test(content)) { - unconfigured.push(name); - continue; - } - - // Parse optional YAML frontmatter for schedule and target-metric - // Strip leading HTML comments before checking (issue-based programs may have them) - const contentStripped = content.replace(/^(\s*\s*\n)*/, ''); - let scheduleDelta = null; - let targetMetric = null; - const fmMatch = contentStripped.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); - if (fmMatch) { - for (const line of fmMatch[1].split('\n')) { - if (line.trim().startsWith('schedule:')) { - const scheduleStr = line.substring(line.indexOf(':') + 1).trim(); - scheduleDelta = parseSchedule(scheduleStr); - } - if (line.trim().startsWith('target-metric:')) { - const val = parseFloat(line.substring(line.indexOf(':') + 1).trim()); - if (!isNaN(val)) { - targetMetric = val; - } else { - console.log(' Warning: ' + name + ' has invalid target-metric value: ' + line.substring(line.indexOf(':') + 1).trim()); - } - } - } - } - - // Read state from repo-memory - const state = readProgramState(name); - if (state && Object.keys(state).length > 0) { - console.log(' ' + name + ': last_run=' + (state.last_run || null) + ', iteration_count=' + (state.iteration_count != null ? state.iteration_count : null)); - } else { - console.log(' ' + name + ': no state found (first run)'); - } - - let lastRun = null; - const lr = state.last_run || null; - if (lr) { - try { - const d = new Date(lr); - if (!isNaN(d.getTime())) lastRun = d; - } catch (e) { - // ignore invalid date - } - } - - // Check if completed (target metric was reached) - if (String(state.completed || '').toLowerCase() === 'true') { - skipped.push({ name: name, reason: 'completed: target metric reached' }); - continue; - } - - // Check if paused (e.g., plateau or recurring errors) - if (state.paused) { - skipped.push({ name: name, reason: 'paused: ' + (state.pause_reason || 'unknown') }); - continue; - } - - // Auto-pause on plateau: 5+ consecutive rejections - const recent = (state.recent_statuses || []).slice(-5); - if (recent.length >= 5 && recent.every(s => s === 'rejected')) { - skipped.push({ name: name, reason: 'plateau: 5 consecutive rejections' }); - continue; - } - - // Check if due based on per-program schedule - if (scheduleDelta && lastRun) { - if (now.getTime() - lastRun.getTime() < scheduleDelta) { - skipped.push({ - name: name, - reason: 'not due yet', - next_due: new Date(lastRun.getTime() + scheduleDelta).toISOString(), - }); - continue; - } - } - - due.push({ name: name, last_run: lr, file: pf, target_metric: targetMetric }); - } - - // Pick the program to run - let selected = null; - let selectedFile = null; - let selectedIssue = null; - let selectedTargetMetric = null; - let deferred = []; - - if (forcedProgram) { - // Manual dispatch requested a specific program -- bypass scheduling - // (paused, not-due, and plateau programs can still be forced) - if (!(forcedProgram in allPrograms)) { - console.log("ERROR: requested program '" + forcedProgram + "' not found."); - console.log(' Available programs: ' + JSON.stringify(Object.keys(allPrograms))); - process.exit(1); - } - if (unconfigured.includes(forcedProgram)) { - console.log("ERROR: requested program '" + forcedProgram + "' is unconfigured (has placeholders)."); - process.exit(1); - } - selected = forcedProgram; - selectedFile = allPrograms[forcedProgram]; - deferred = due.filter(p => p.name !== forcedProgram).map(p => p.name); - if (selected in issuePrograms) { - selectedIssue = issuePrograms[selected].issue_number; - } - // Find target_metric: check the due list first, then parse from the program file - for (const p of due) { - if (p.name === forcedProgram) { - selectedTargetMetric = p.target_metric || null; - break; - } - } - if (selectedTargetMetric === null) { - // Program may have been skipped (completed/paused/plateau) -- parse directly - try { - const _content = fs.readFileSync(selectedFile, 'utf-8'); - const _contentStripped = _content.replace(/^(\s*\s*\n)*/, ''); - const _fm = _contentStripped.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); - if (_fm) { - for (const _line of _fm[1].split('\n')) { - if (_line.trim().startsWith('target-metric:')) { - const val = parseFloat(_line.substring(_line.indexOf(':') + 1).trim()); - if (!isNaN(val)) { - selectedTargetMetric = val; - break; - } - } - } - } - } catch (e) { /* ignore */ } - } - console.log("FORCED: running program '" + forcedProgram + "' (manual dispatch)"); - } else if (due.length > 0) { - // Normal scheduling: pick the single most-overdue program - due.sort((a, b) => (a.last_run || '').localeCompare(b.last_run || '')); // null/empty sorts first (never run) - selected = due[0].name; - selectedFile = due[0].file; - selectedTargetMetric = due[0].target_metric || null; - deferred = due.slice(1).map(p => p.name); - // Check if the selected program is issue-based - if (selected in issuePrograms) { - selectedIssue = issuePrograms[selected].issue_number; - } - } - - const issueProgramsMap = {}; - for (const [name, info] of Object.entries(issuePrograms)) { - issueProgramsMap[name] = info.issue_number; - } - - const notDue = !selected && unconfigured.length === 0; - const result = { - selected: selected, - selected_file: selectedFile, - selected_issue: selectedIssue, - selected_target_metric: selectedTargetMetric, - issue_programs: issueProgramsMap, - deferred: deferred, - skipped: skipped, - unconfigured: unconfigured, - no_programs: false, - not_due: notDue, - }; - - fs.mkdirSync('/tmp/gh-aw', { recursive: true }); - fs.writeFileSync('/tmp/gh-aw/autoloop.json', JSON.stringify(result, null, 2)); - - console.log('=== Autoloop Program Check ==='); - console.log('Selected program: ' + (selected || '(none)') + ' (' + (selectedFile || 'n/a') + ')'); - console.log('Deferred (next run): ' + (deferred.length > 0 ? JSON.stringify(deferred) : '(none)')); - console.log('Programs skipped: ' + (skipped.length > 0 ? JSON.stringify(skipped.map(s => s.name)) : '(none)')); - console.log('Programs unconfigured: ' + (unconfigured.length > 0 ? JSON.stringify(unconfigured) : '(none)')); - - if (!selected && unconfigured.length === 0) { - console.log('\nNo programs due this run. Exiting early.'); - process.exit(0); - } - } - - main().catch(err => { console.error(err.message || err); process.exit(1); }); - JSEOF + python3 .github/workflows/scripts/autoloop_scheduler.py source: githubnext/autoloop engine: copilot diff --git a/workflows/scripts/autoloop_scheduler.py b/workflows/scripts/autoloop_scheduler.py new file mode 100644 index 0000000..5066afe --- /dev/null +++ b/workflows/scripts/autoloop_scheduler.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +"""Autoloop scheduler. + +Decides which Autoloop program (if any) is due for an iteration. Reads +program definitions from ``.autoloop/programs/`` (directory- and bare- +markdown-based) and from open GitHub issues labelled ``autoloop-program``, +combines them with persisted per-program scheduling state from the +``memory/autoloop`` repo-memory branch, and writes the selection to +``/tmp/gh-aw/autoloop.json`` for the agent step to consume. + +Side effects: + * May bootstrap ``.autoloop/programs/example.md`` on first run. + * May materialise issue-based program bodies under + ``/tmp/gh-aw/issue-programs/``. + * Always writes ``/tmp/gh-aw/autoloop.json``. + +Exit codes: + 0 - a program was selected, or there are unconfigured programs to + report on (the agent step should run). + 1 - nothing to do this run (no due programs, no unconfigured + programs); the workflow should skip the agent step. + +Environment variables: + GITHUB_TOKEN - token used to query the issues API. + GITHUB_REPOSITORY - ``owner/repo`` slug. + AUTOLOOP_PROGRAM - optional program name to force (bypasses + scheduling, but unconfigured programs are still + rejected). + +This file is the standalone counterpart of the inline scheduler that +previously lived in ``workflows/autoloop.md``. Extracting it keeps the +compiled ``run:`` step small (avoiding GitHub Actions' inline-expression +size limit) and makes the logic unit-testable from ``tests/``. +""" + +from __future__ import annotations + +import glob +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone + +PROGRAMS_DIR = ".autoloop/programs" +TEMPLATE_FILE = os.path.join(PROGRAMS_DIR, "example.md") + +# Repo-memory files are cloned to /tmp/gh-aw/repo-memory/{id}/ where {id} +# is derived from the branch-name configured in the tools section +# (memory/autoloop -> autoloop). +REPO_MEMORY_DIR = "/tmp/gh-aw/repo-memory/autoloop" + +ISSUE_PROGRAMS_DIR = "/tmp/gh-aw/issue-programs" +OUTPUT_DIR = "/tmp/gh-aw" +OUTPUT_FILE = os.path.join(OUTPUT_DIR, "autoloop.json") + + +# --------------------------------------------------------------------------- +# Pure helpers (unit-tested directly) +# --------------------------------------------------------------------------- + + +def parse_machine_state(content): + """Parse the ⚙️ Machine State table from a state file. Returns a dict.""" + state = {} + m = re.search(r"## ⚙️ Machine State.*?\n(.*?)(?=\n## |\Z)", content, re.DOTALL) + if not m: + return state + section = m.group(0) + for row in re.finditer(r"\|\s*(.+?)\s*\|\s*(.+?)\s*\|", section): + raw_key = row.group(1).strip() + raw_val = row.group(2).strip() + if raw_key.lower() in ("field", "---", ":---", ":---:", "---:"): + continue + key = raw_key.lower().replace(" ", "_") + val = None if raw_val in ("—", "-", "") else raw_val + state[key] = val + # Coerce types + for int_field in ("iteration_count", "consecutive_errors"): + if int_field in state: + try: + state[int_field] = int(state[int_field]) + except (ValueError, TypeError): + state[int_field] = 0 + if "paused" in state: + state["paused"] = str(state.get("paused", "")).lower() == "true" + if "completed" in state: + state["completed"] = str(state.get("completed", "")).lower() == "true" + # recent_statuses: stored as comma-separated words (e.g. "accepted, rejected, error") + rs_raw = state.get("recent_statuses") or "" + if rs_raw: + state["recent_statuses"] = [s.strip().lower() for s in rs_raw.split(",") if s.strip()] + else: + state["recent_statuses"] = [] + return state + + +def parse_schedule(s): + """Schedule string to a ``timedelta``; returns ``None`` for invalid input.""" + s = s.strip().lower() + m = re.match(r"every\s+(\d+)\s*h", s) + if m: + return timedelta(hours=int(m.group(1))) + m = re.match(r"every\s+(\d+)\s*m", s) + if m: + return timedelta(minutes=int(m.group(1))) + if s == "daily": + return timedelta(hours=24) + if s == "weekly": + return timedelta(days=7) + return None + + +def get_program_name(pf): + """Extract program name from a program file path. + + Directory-based: ``.autoloop/programs//program.md`` -> ```` + Bare markdown: ``.autoloop/programs/.md`` -> ```` + Issue-based: ``/tmp/gh-aw/issue-programs/.md`` -> ```` + """ + if pf.endswith("/program.md"): + return os.path.basename(os.path.dirname(pf)) + return os.path.splitext(os.path.basename(pf))[0] + + +def slugify_issue_title(title, number=None): + """Slugify a GitHub issue title into a program name.""" + slug = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-") + slug = re.sub(r"-+", "-", slug) # collapse consecutive hyphens + if not slug: + slug = "issue-{}".format(number) if number is not None else "issue" + return slug + + +def parse_link_header(header): + """Parse the GitHub API ``Link`` header and return the ``rel="next"`` URL.""" + if not header: + return None + for part in header.split(","): + section = part.strip() + m = re.match(r'^<([^>]+)>;\s*rel="next"$', section) + if m: + return m.group(1) + return None + + +def parse_program_frontmatter(content): + """Parse optional YAML frontmatter for ``schedule`` and ``target-metric``. + + Returns ``(schedule_delta, target_metric, target_metric_invalid_value)``. + The third element is the raw string of an invalid ``target-metric`` value + (so the caller can warn), or ``None`` when the value parsed cleanly or was + absent. + """ + # Strip leading HTML comments before checking (issue-based programs may have them). + content_stripped = re.sub(r"^(\s*\s*\n)*", "", content, flags=re.DOTALL) + schedule_delta = None + target_metric = None + target_metric_invalid = None + fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content_stripped, re.DOTALL) + if not fm_match: + return schedule_delta, target_metric, target_metric_invalid + for line in fm_match.group(1).split("\n"): + if line.strip().startswith("schedule:"): + schedule_str = line.split(":", 1)[1].strip() + schedule_delta = parse_schedule(schedule_str) + if line.strip().startswith("target-metric:"): + raw = line.split(":", 1)[1].strip() + try: + target_metric = float(raw) + except (ValueError, TypeError): + target_metric_invalid = raw + return schedule_delta, target_metric, target_metric_invalid + + +def is_unconfigured(content): + """Return True if a program file still contains the unconfigured sentinel + or any TODO/REPLACE placeholder.""" + if "" in content: + return True + if re.search(r"\bTODO\b|\bREPLACE", content): + return True + return False + + +def check_skip_conditions(state): + """Return ``(should_skip, reason)`` based on the program state.""" + if str(state.get("completed", "")).lower() == "true" or state.get("completed") is True: + return True, "completed: target metric reached" + if state.get("paused"): + return True, "paused: {}".format(state.get("pause_reason", "unknown")) + recent = state.get("recent_statuses", [])[-5:] + if len(recent) >= 5 and all(s == "rejected" for s in recent): + return True, "plateau: 5 consecutive rejections" + return False, None + + +# --------------------------------------------------------------------------- +# I/O helpers +# --------------------------------------------------------------------------- + + +def read_program_state(program_name, repo_memory_dir=REPO_MEMORY_DIR): + """Read scheduling state from the repo-memory state file (or ``{}``).""" + state_file = os.path.join(repo_memory_dir, "{}.md".format(program_name)) + if not os.path.isfile(state_file): + print(" {}: no state file found (first run)".format(program_name)) + return {} + with open(state_file, encoding="utf-8") as f: + content = f.read() + return parse_machine_state(content) + + +def _bootstrap_template_if_missing(): + """Create ``.autoloop/programs/example.md`` if the directory is missing.""" + if os.path.isdir(PROGRAMS_DIR): + return + os.makedirs(PROGRAMS_DIR, exist_ok=True) + bt = chr(96) # backtick — keep gh-aw compiler happy if this ever gets inlined + template = "\n".join([ + "", + "", + "", + "", + "# Autoloop Program", + "", + "", + "", + "## Goal", + "", + "", + "", + "REPLACE THIS with your optimization goal.", + "", + "## Target", + "", + "", + "", + "Only modify these files:", + "- {bt}REPLACE_WITH_FILE{bt} -- (describe what this file does)".format(bt=bt), + "", + "Do NOT modify:", + "- (list files that must not be touched)", + "", + "## Evaluation", + "", + "", + "", + "{bt}{bt}{bt}bash".format(bt=bt), + "REPLACE_WITH_YOUR_EVALUATION_COMMAND", + "{bt}{bt}{bt}".format(bt=bt), + "", + "The metric is {bt}REPLACE_WITH_METRIC_NAME{bt}. **Lower/Higher is better.** (pick one)".format(bt=bt), + "", + ]) + with open(TEMPLATE_FILE, "w") as f: + f.write(template) + # Leave the template unstaged — the agent will create a draft PR with it + print("BOOTSTRAPPED: created {} locally (agent will create a draft PR)".format(TEMPLATE_FILE)) + + +def _scan_directory_programs(): + """Return paths of directory-based programs under ``PROGRAMS_DIR``.""" + out = [] + if not os.path.isdir(PROGRAMS_DIR): + return out + for entry in sorted(os.listdir(PROGRAMS_DIR)): + prog_dir = os.path.join(PROGRAMS_DIR, entry) + if os.path.isdir(prog_dir): + prog_file = os.path.join(prog_dir, "program.md") + if os.path.isfile(prog_file): + out.append(prog_file) + return out + + +def _scan_bare_programs(): + """Return paths of bare-markdown programs under ``PROGRAMS_DIR``.""" + return sorted(glob.glob(os.path.join(PROGRAMS_DIR, "*.md"))) + + +def _fetch_issue_programs(repo, github_token): + """Fetch open issues with the ``autoloop-program`` label and write their + bodies to ``ISSUE_PROGRAMS_DIR``. Returns ``(program_files, issue_programs)``. + + Errors are swallowed (with a warning) so a transient API failure doesn't + block the run for non-issue-based programs. + """ + program_files = [] + issue_programs = {} + os.makedirs(ISSUE_PROGRAMS_DIR, exist_ok=True) + next_url = ( + "https://api.github.com/repos/{}/issues" + "?labels=autoloop-program&state=open&per_page=100".format(repo) + ) + headers = { + "Authorization": "token {}".format(github_token), + "Accept": "application/vnd.github.v3+json", + } + issues = [] + try: + while next_url: + req = urllib.request.Request(next_url, headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: + page = json.loads(resp.read().decode()) + link_header = resp.headers.get("link") or resp.headers.get("Link") + issues.extend(page) + next_url = parse_link_header(link_header) + for issue in issues: + if issue.get("pull_request"): + continue # skip PRs + body = issue.get("body") or "" + title = issue.get("title") or "" + number = issue["number"] + slug = slugify_issue_title(title, number) + if slug in issue_programs: + print( + " Warning: slug '{}' (issue #{}) collides with issue #{}, " + "appending issue number".format( + slug, number, issue_programs[slug]["issue_number"] + ) + ) + slug = "{}-{}".format(slug, number) + issue_file = os.path.join(ISSUE_PROGRAMS_DIR, "{}.md".format(slug)) + with open(issue_file, "w") as f: + f.write(body) + program_files.append(issue_file) + issue_programs[slug] = {"issue_number": number, "file": issue_file, "title": title} + print(" Found issue-based program: '{}' (issue #{})".format(slug, number)) + except Exception as e: # noqa: BLE001 -- best-effort; logged below + print(" Warning: could not fetch issue-based programs: {}".format(e)) + return program_files, issue_programs + + +def _parse_target_metric_from_file(path): + """Re-parse a program file to extract its ``target-metric``, if any.""" + try: + with open(path) as f: + _, target_metric, _ = parse_program_frontmatter(f.read()) + return target_metric + except (OSError, ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Selection +# --------------------------------------------------------------------------- + + +def select_program(due, forced_program=None, all_programs=None, unconfigured=None, issue_programs=None): + """Pick the program to run. + + Returns ``(selected, selected_file, selected_issue, selected_target_metric, + deferred, error)``. ``error`` is a string describing why a forced selection + failed (and the caller should ``sys.exit(1)``); otherwise it is ``None``. + """ + all_programs = all_programs or {} + unconfigured = unconfigured or [] + issue_programs = issue_programs or {} + if forced_program: + if forced_program not in all_programs: + return ( + None, None, None, None, [], + "requested program '{}' not found. Available programs: {}".format( + forced_program, list(all_programs.keys()) + ), + ) + if forced_program in unconfigured: + return ( + None, None, None, None, [], + "requested program '{}' is unconfigured (has placeholders).".format( + forced_program + ), + ) + selected = forced_program + selected_file = all_programs[forced_program] + deferred = [p["name"] for p in due if p["name"] != forced_program] + selected_issue = ( + issue_programs[selected]["issue_number"] if selected in issue_programs else None + ) + selected_target_metric = None + for p in due: + if p["name"] == forced_program: + selected_target_metric = p.get("target_metric") + break + if selected_target_metric is None: + selected_target_metric = _parse_target_metric_from_file(selected_file) + return selected, selected_file, selected_issue, selected_target_metric, deferred, None + + if due: + # Normal scheduling: pick the single most-overdue program. + # ``last_run`` of None/empty sorts first (never run). + due_sorted = sorted(due, key=lambda p: p["last_run"] or "") + selected = due_sorted[0]["name"] + selected_file = due_sorted[0]["file"] + selected_target_metric = due_sorted[0].get("target_metric") + deferred = [p["name"] for p in due_sorted[1:]] + selected_issue = ( + issue_programs[selected]["issue_number"] if selected in issue_programs else None + ) + return selected, selected_file, selected_issue, selected_target_metric, deferred, None + + return None, None, None, None, [], None + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + github_token = os.environ.get("GITHUB_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + forced_program = os.environ.get("AUTOLOOP_PROGRAM", "").strip() + + _bootstrap_template_if_missing() + + # Find all program files from all locations: + # 1. Directory-based programs: .autoloop/programs//program.md (preferred) + # 2. Bare markdown programs: .autoloop/programs/.md (simple) + # 3. Issue-based programs: GitHub issues with the 'autoloop-program' label + program_files = [] + program_files.extend(_scan_directory_programs()) + program_files.extend(_scan_bare_programs()) + issue_files, issue_programs = _fetch_issue_programs(repo, github_token) + program_files.extend(issue_files) + + if not program_files: + # Fallback to single-file locations + for path in [".autoloop/program.md", "program.md"]: + if os.path.isfile(path): + program_files = [path] + break + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + if not program_files: + print("NO_PROGRAMS_FOUND") + with open(OUTPUT_FILE, "w") as f: + json.dump( + {"due": [], "skipped": [], "unconfigured": [], "no_programs": True}, f + ) + sys.exit(0) + + now = datetime.now(timezone.utc) + due = [] + skipped = [] + unconfigured = [] + all_programs = {} # name -> file path + + for pf in program_files: + name = get_program_name(pf) + all_programs[name] = pf + with open(pf) as f: + content = f.read() + + if is_unconfigured(content): + unconfigured.append(name) + continue + + schedule_delta, target_metric, invalid_target = parse_program_frontmatter(content) + if invalid_target is not None: + print(" Warning: {} has invalid target-metric value: {}".format(name, invalid_target)) + + # Read state from repo-memory + state = read_program_state(name) + if state: + print( + " {}: last_run={}, iteration_count={}".format( + name, state.get("last_run"), state.get("iteration_count") + ) + ) + else: + print(" {}: no state found (first run)".format(name)) + + last_run = None + lr = state.get("last_run") + if lr: + try: + last_run = datetime.fromisoformat(lr.replace("Z", "+00:00")) + except ValueError: + pass + + should_skip, reason = check_skip_conditions(state) + if should_skip: + skipped.append({"name": name, "reason": reason}) + continue + + # Check if due based on per-program schedule + if schedule_delta and last_run and now - last_run < schedule_delta: + skipped.append( + { + "name": name, + "reason": "not due yet", + "next_due": (last_run + schedule_delta).isoformat(), + } + ) + continue + + due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric}) + + selected, selected_file, selected_issue, selected_target_metric, deferred, error = ( + select_program(due, forced_program, all_programs, unconfigured, issue_programs) + ) + + if error: + print("ERROR: {}".format(error)) + sys.exit(1) + + if forced_program and selected: + print("FORCED: running program '{}' (manual dispatch)".format(forced_program)) + + result = { + "selected": selected, + "selected_file": selected_file, + "selected_issue": selected_issue, + "selected_target_metric": selected_target_metric, + "issue_programs": { + name: info["issue_number"] for name, info in issue_programs.items() + }, + "deferred": deferred, + "skipped": skipped, + "unconfigured": unconfigured, + "no_programs": False, + } + + with open(OUTPUT_FILE, "w") as f: + json.dump(result, f, indent=2) + + print("=== Autoloop Program Check ===") + print("Selected program: {} ({})".format(selected or "(none)", selected_file or "n/a")) + print("Deferred (next run): {}".format(deferred or "(none)")) + print("Programs skipped: {}".format([s["name"] for s in skipped] or "(none)")) + print("Programs unconfigured: {}".format(unconfigured or "(none)")) + + if not selected and not unconfigured: + print("\nNo programs due this run. Exiting early.") + sys.exit(1) # Non-zero exit skips the agent step + + +if __name__ == "__main__": + main()