Skip to content

Commit 67a741a

Browse files
mrjfclaude
andcommitted
Add lightweight pre-step to skip agent when no programs are due
The pre-step runs in Python before the agent starts and checks: - Which programs are due based on per-program schedule + last_run - Which are unconfigured (sentinel/placeholders still present) - Which are paused or plateaued (5+ consecutive rejections) If no programs are due, the workflow exits with no agent invocation. This avoids burning agent compute on schedule ticks where nothing needs to happen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e1b6061 commit 67a741a

1 file changed

Lines changed: 137 additions & 10 deletions

File tree

workflows/autoloop.md

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,131 @@ tools:
6363
imports:
6464
- shared/reporting.md
6565

66+
steps:
67+
- name: Check which programs are due
68+
run: |
69+
python3 - << 'PYEOF'
70+
import os, json, re, glob, sys
71+
from datetime import datetime, timezone, timedelta
72+
73+
programs_dir = ".github/autoloop/programs"
74+
memory_dir = ".github/repo-memory/autoloop"
75+
76+
# Find all program files
77+
program_files = []
78+
if os.path.isdir(programs_dir):
79+
program_files = sorted(glob.glob(os.path.join(programs_dir, "*.md")))
80+
if not program_files:
81+
# Fallback to single-file locations
82+
for path in [".github/autoloop/program.md", "program.md"]:
83+
if os.path.isfile(path):
84+
program_files = [path]
85+
break
86+
87+
if not program_files:
88+
print("NO_PROGRAMS_FOUND")
89+
with open("/tmp/gh-aw/autoloop.json", "w") as f:
90+
json.dump({"due": [], "skipped": [], "unconfigured": [], "no_programs": True}, f)
91+
sys.exit(0)
92+
93+
now = datetime.now(timezone.utc)
94+
due = []
95+
skipped = []
96+
unconfigured = []
97+
98+
# Schedule string to timedelta
99+
def parse_schedule(s):
100+
s = s.strip().lower()
101+
m = re.match(r"every\s+(\d+)\s*h", s)
102+
if m:
103+
return timedelta(hours=int(m.group(1)))
104+
m = re.match(r"every\s+(\d+)\s*m", s)
105+
if m:
106+
return timedelta(minutes=int(m.group(1)))
107+
if s == "daily":
108+
return timedelta(hours=24)
109+
if s == "weekly":
110+
return timedelta(days=7)
111+
return None # No per-program schedule — always due
112+
113+
for pf in program_files:
114+
name = os.path.splitext(os.path.basename(pf))[0]
115+
with open(pf) as f:
116+
content = f.read()
117+
118+
# Check sentinel
119+
if "<!-- AUTOLOOP:UNCONFIGURED -->" in content:
120+
unconfigured.append(name)
121+
continue
122+
123+
# Check for TODO/REPLACE placeholders
124+
if re.search(r'\bTODO\b|\bREPLACE', content):
125+
unconfigured.append(name)
126+
continue
127+
128+
# Parse optional YAML frontmatter for schedule
129+
schedule_delta = None
130+
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
131+
if fm_match:
132+
for line in fm_match.group(1).split("\n"):
133+
if line.strip().startswith("schedule:"):
134+
schedule_str = line.split(":", 1)[1].strip()
135+
schedule_delta = parse_schedule(schedule_str)
136+
137+
# Check last_run from repo memory
138+
mem_file = os.path.join(memory_dir, f"{name}.json")
139+
last_run = None
140+
if os.path.isfile(mem_file):
141+
try:
142+
with open(mem_file) as f:
143+
mem = json.load(f)
144+
lr = mem.get("last_run")
145+
if lr:
146+
last_run = datetime.fromisoformat(lr.replace("Z", "+00:00"))
147+
except (json.JSONDecodeError, ValueError):
148+
pass
149+
150+
# Check if paused (e.g., plateau or recurring errors)
151+
if os.path.isfile(mem_file):
152+
try:
153+
with open(mem_file) as f:
154+
mem = json.load(f)
155+
if mem.get("paused"):
156+
skipped.append({"name": name, "reason": f"paused: {mem.get('pause_reason', 'unknown')}"})
157+
continue
158+
# Auto-pause on plateau: 5+ consecutive rejections
159+
recent = mem.get("history", [])[-5:]
160+
if len(recent) >= 5 and all(h.get("status") == "rejected" for h in recent):
161+
skipped.append({"name": name, "reason": "plateau: 5 consecutive rejections"})
162+
continue
163+
except (json.JSONDecodeError, ValueError):
164+
pass
165+
166+
# Check if due based on per-program schedule
167+
if schedule_delta and last_run:
168+
if now - last_run < schedule_delta:
169+
skipped.append({"name": name, "reason": "not due yet",
170+
"next_due": (last_run + schedule_delta).isoformat()})
171+
continue
172+
173+
due.append(name)
174+
175+
result = {"due": due, "skipped": skipped, "unconfigured": unconfigured, "no_programs": False}
176+
177+
os.makedirs("/tmp/gh-aw", exist_ok=True)
178+
with open("/tmp/gh-aw/autoloop.json", "w") as f:
179+
json.dump(result, f, indent=2)
180+
181+
print("=== Autoloop Program Check ===")
182+
print(f"Programs due: {due or '(none)'}")
183+
print(f"Programs skipped: {[s['name'] for s in skipped] or '(none)'}")
184+
print(f"Programs unconfigured: {unconfigured or '(none)'}")
185+
186+
if not due and not unconfigured:
187+
print("\nNo programs due this run. Exiting early.")
188+
sys.exit(1) # Non-zero exit skips the agent step
189+
PYEOF
190+
66191
---
67192

68193
# Autoloop
@@ -99,7 +224,7 @@ Each program runs independently with its own:
99224
- PR title prefix: `[Autoloop: {program-name}]`
100225
- Repo memory namespace: keyed by program name
101226

102-
On each scheduled run, the workflow iterates through **all configured programs** and runs one iteration per program. Programs with the `<!-- AUTOLOOP:UNCONFIGURED -->` sentinel are skipped.
227+
On each scheduled run, a lightweight pre-step checks which programs are due (based on per-program schedules and `last_run` timestamps). **If no programs are due, the workflow exits before the agent starts — zero agent cost.** Only due programs get iterated.
103228

104229
### Per-Program Schedule and Timeout
105230

@@ -151,16 +276,18 @@ If **all** programs are unconfigured, exit after creating the setup issues. Othe
151276

152277
### Reading Programs
153278

154-
At the start of every run:
279+
The pre-step has already determined which programs are due, unconfigured, or skipped. Read `/tmp/gh-aw/autoloop.json` at the start of your run to get:
280+
281+
- **`due`**: List of program names to run iterations for this run.
282+
- **`unconfigured`**: Programs that still have the sentinel or placeholder content — run the **Setup Guard** for each of these (create setup issues).
283+
- **`skipped`**: Programs not due yet based on their per-program schedule — ignore these entirely.
284+
- **`no_programs`**: If `true`, no program files exist at all — create a single issue explaining how to add a program.
155285

156-
1. List all `.md` files in `.github/autoloop/programs/`.
157-
2. If the directory is empty or doesn't exist, also check for a single `.github/autoloop/program.md` or `program.md` in the repo root as a fallback (for single-program setups).
158-
3. For each program file:
159-
a. Check for the `<!-- AUTOLOOP:UNCONFIGURED -->` sentinel — if present, run the **Setup Guard** for that program and skip it.
160-
b. Parse the three sections: Goal, Target, Evaluation.
161-
c. Validate that all three sections have non-placeholder content. If any section still contains `TODO` or `REPLACE` markers, treat it as unconfigured — create/update the setup issue for that program and skip it.
162-
d. Read the current state of all target files.
163-
e. Read repo memory for that program's metric history (keyed by program name).
286+
For each program in `due`:
287+
1. Read the program file from `.github/autoloop/programs/{name}.md`.
288+
2. Parse the three sections: Goal, Target, Evaluation.
289+
3. Read the current state of all target files.
290+
4. Read repo memory for that program's metric history (keyed by program name).
164291

165292
## Iteration Loop
166293

0 commit comments

Comments
 (0)