@@ -84,6 +84,33 @@ 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+ # Pass the token via an http.extraHeader rather than embedding it in the
103+ # URL — keeps it out of process listings and any logged remote URLs.
104+ AUTH_HEADER="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
105+ git -c "http.extraHeader=${AUTH_HEADER}" clone --depth=1 --branch memory/autoloop \
106+ "https://github.com/${GITHUB_REPOSITORY}.git" \
107+ /tmp/gh-aw/repo-memory/autoloop \
108+ || {
109+ echo "memory/autoloop branch not found (first run); creating empty dir"
110+ mkdir -p /tmp/gh-aw/repo-memory/autoloop
111+ }
112+ fi
113+
87114 - name : Check which programs are due
88115 env :
89116 GITHUB_TOKEN : ${{ github.token }}
@@ -409,7 +436,8 @@ steps:
409436 "next_due": (last_run + schedule_delta).isoformat()})
410437 continue
411438
412- due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric})
439+ due.append({"name": name, "last_run": lr, "file": pf, "target_metric": target_metric,
440+ "schedule_seconds": schedule_delta.total_seconds() if schedule_delta else None})
413441
414442 # Pick the program to run
415443 selected = None
@@ -457,8 +485,19 @@ steps:
457485 pass
458486 print(f"FORCED: running program '{forced_program}' (manual dispatch)")
459487 elif due:
460- # Normal scheduling: pick the single most-overdue program
461- due.sort(key=lambda p: p["last_run"] or "") # None/empty sorts first (never run)
488+ # Normal scheduling: pick the single most-overdue program.
489+ # Tiebreaker rationale: programs that have never run (no last_run) take
490+ # priority over ever-run programs; among never-run programs, prefer the
491+ # shortest schedule (so "every 30m" beats "every 6h"), then alphabetical
492+ # by name. Programs with no parseable schedule sort last among never-run
493+ # programs (float('inf')). This avoids permanent starvation when state
494+ # is missing — see issue: "Autoloop pre-step can't read state files".
495+ def _due_sort_key(p):
496+ if p["last_run"]:
497+ return (1, p["last_run"], p["name"])
498+ sched = p.get("schedule_seconds")
499+ return (0, sched if sched is not None else float("inf"), p["name"])
500+ due.sort(key=_due_sort_key)
462501 selected = due[0]["name"]
463502 selected_file = due[0]["file"]
464503 selected_target_metric = due[0].get("target_metric")
0 commit comments