@@ -84,6 +84,30 @@ imports:
8484 - shared/reporting.md
8585
8686steps :
87+ - name : Clone repo-memory for scheduler
88+ # The "Check which programs are due" step below reads program state from
89+ # /tmp/gh-aw/repo-memory/autoloop/, but gh-aw's built-in repo-memory clone
90+ # runs *after* this pre-step (and clones to a different directory). Without
91+ # this step, every program looks like a "first run" and the tiebreaker
92+ # starves programs that lose the original-insertion-order tiebreak.
93+ # See issue: "Autoloop pre-step can't read state files".
94+ env :
95+ GITHUB_TOKEN : ${{ github.token }}
96+ GITHUB_REPOSITORY : ${{ github.repository }}
97+ run : |
98+ mkdir -p /tmp/gh-aw/repo-memory
99+ if [ -d /tmp/gh-aw/repo-memory/autoloop/.git ]; then
100+ echo "repo-memory/autoloop already cloned; skipping"
101+ else
102+ git clone --depth=1 --branch memory/autoloop \
103+ "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
104+ /tmp/gh-aw/repo-memory/autoloop \
105+ || {
106+ echo "memory/autoloop branch not found (first run); creating empty dir"
107+ mkdir -p /tmp/gh-aw/repo-memory/autoloop
108+ }
109+ fi
110+
87111 - name : Check which programs are due
88112 env :
89113 GITHUB_TOKEN : ${{ github.token }}
@@ -377,7 +401,8 @@ steps:
377401 "next_due": (last_run + schedule_delta).isoformat()})
378402 continue
379403
380- due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric})
404+ due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric,
405+ "schedule_seconds": schedule_delta.total_seconds() if schedule_delta else None})
381406
382407 # Pick the program to run
383408 selected = None
@@ -422,8 +447,18 @@ steps:
422447 pass
423448 print(f"FORCED: running program '{forced_program}' (manual dispatch)")
424449 elif due:
425- # Normal scheduling: pick the single most-overdue program
426- due.sort(key=lambda p: p["last_run"] or "") # None/empty sorts first (never run)
450+ # Normal scheduling: pick the single most-overdue program.
451+ # Tiebreaker rationale: programs that have never run (no last_run) take
452+ # priority over ever-run programs; among never-run programs, prefer the
453+ # shortest schedule (so "every 30m" beats "every 6h"), then alphabetical
454+ # by name. This avoids permanent starvation when state is missing — see
455+ # issue: "Autoloop pre-step can't read state files".
456+ def _due_sort_key(p):
457+ if p["last_run"]:
458+ return (1, p["last_run"], p["name"])
459+ sched = p.get("schedule_seconds")
460+ return (0, sched if sched is not None else float("inf"), p["name"])
461+ due.sort(key=_due_sort_key)
427462 selected = due[0]["name"]
428463 selected_file = due[0]["file"]
429464 selected_target_metric = due[0].get("target_metric")
0 commit comments