Summary
The current Autoloop workflow has a "single long-running branch, single draft PR, per program" invariant stated in prose but not defended in code or config. gh-aw's default safe-outputs behavior silently auto-suffixes branch names to avoid collisions, which breaks the invariant on the second iteration of every program (the moment the first PR merges and the branch collides with itself). The result is a growing stack of draft PRs per program — one new PR per accepted iteration — instead of a single accumulating PR.
This is actually three overlapping problems and the fix needs three coordinated pieces.
Observed failure mode
Without the fix, after the first iteration's PR merges:
- The framework-managed
create-pull-request safe-output appends a random hex salt: autoloop/{program}-8724e9f9.
- Each accepted iteration creates a new branch + PR.
- A long-running program ends up with 10+ draft PRs and no single canonical branch reviewers can track.
Fix — three coordinated pieces
A. Config: preserve-branch-name: true in safe-outputs
The create-pull-request safe-output needs explicit opt-in to preserve the agent-chosen branch name. Without this, the framework appends -<hex> on every run to avoid collisions — which defeats the whole single-branch model.
```diff
safe-outputs:
create-pull-request:
draft: true
title-prefix: "[Autoloop] "
labels: [automation, autoloop]
protected-files: fallback-to-issue
- preserve-branch-name: true
- max: 1
push-to-pull-request-branch:
target: "*"
title-prefix: "[Autoloop] "
max: 1 on both, because the invariant is one safe-output of either kind per iteration — never a create+create or a create+push in the same run.
B. Scheduler: emit `existing_pr` and `head_branch` to `autoloop.json`
The scheduler (once extracted — see sibling issue #34) should query the GitHub API for the open PR matching the program's branch and expose two new fields:
existing_pr: PR number of the open draft PR for autoloop/{program-name}, or null if none.
head_branch: The canonical branch name, always exactly autoloop/{program-name}.
The lookup should be tolerant of legacy framework-suffixed branches (e.g., autoloop/{name}-[0-9a-f]{8} or -[0-9a-f]{16}) so existing installations upgrading to the new scheduler find their in-flight PRs rather than opening a second one:
```python
Strategy 1: exact canonical name
prs = gh_api(f"/repos/{repo}/pulls?head={owner}:autoloop/{program}&state=open")
Strategy 2 fallback: framework-suffixed branch OR title-prefix match
if not prs:
for pr in gh_api(f"/repos/{repo}/pulls?state=open&per_page=100"):
head = pr["head"]["ref"]
if re.fullmatch(rf"autoloop/{re.escape(program)}(-[0-9a-f]{{6,40}})?", head):
prs = [pr]
break
if pr["title"].startswith(f"[Autoloop: {program}]"):
prs = [pr]
break
```
Both fields go into /tmp/gh-aw/autoloop.json so the agent's prompt can reference them deterministically.
C. Prose: explicit "no suffixes" warnings + Common Mistakes section
The current prompt tells the agent to "use the canonical branch name" but leaves room for error. Agents have been observed to generate suffixed branch names anyway, so several explicit reinforcements help:
```markdown
⚠️ CRITICAL — Branch Name Must Be Exact
The branch name is ALWAYS exactly `autoloop/{program-name}` — no suffixes, no hashes, no run IDs, no iteration numbers, no random tokens. Never create branches like:
- ❌ `autoloop/coverage-abc123`
- ❌ `autoloop/coverage-iter42-deadbeef`
- ❌ `autoloop/coverage-1234567890`
Never let the gh-aw framework auto-generate a branch name. You must explicitly name the branch when creating it.
```
And a new "Common Mistakes to Avoid" section near the end:
```markdown
Common Mistakes to Avoid
❌ Do NOT create a new branch with a suffix for each iteration.
Correct: `autoloop/coverage`
Wrong: `autoloop/coverage-abc123`, `autoloop/coverage-iter42`, `autoloop/coverage-deadbeef1234`
Use the `head_branch` field from `autoloop.json` — it is always the canonical name.
❌ Do NOT create a new PR if one already exists for `autoloop/{program-name}`.
The pre-step provides `existing_pr` in `autoloop.json`. If it is not null, always use `push-to-pull-request-branch` — never call `create-pull-request`. Only create a PR when `existing_pr` is null AND the state file has no PR number.
```
And in Step 5 (the accept flow), add:
```markdown
Find the existing PR or create one — follow these steps in order:
a. Check `existing_pr` from `/tmp/gh-aw/autoloop.json`. If not null, that is the existing draft PR — use `push-to-pull-request-branch`, never `create-pull-request`.
b. If `existing_pr` is null, also check the `PR` field in the state file's ⚙️ Machine State table as a fallback. Verify it is still open via the GitHub API.
c. If no PR exists (both sources are null): create one with `create-pull-request`, specifying `branch: autoloop/{program-name}` explicitly — do not let the framework auto-generate a branch name.
```
Why all three pieces are needed together
- A without B:
preserve-branch-name: true keeps the name exact but the agent still calls create_pull_request when it should push to the existing PR — the second create call fails loudly because the branch already exists.
- B without A: The agent knows
existing_pr but the framework still appends -<hex> to its chosen branch name the second time the agent bypasses and calls create_pull_request anyway.
- C without A+B: Prose alone doesn't stop the framework from auto-suffixing; the agent does the right thing and the config still sabotages it.
All three close the same loop from different angles and the invariant holds only when all three are present.
Upstream bug that compounds this
gh-aw's create_pull_request.cjs has a separate upstream bug where preserve-branch-name: true is silently bypassed when the remote branch already exists (collision handler unconditionally appends a hex salt, ignoring the flag). Filed as github/gh-aw#27454. Fixing that upstream removes part of the pain here but doesn't replace pieces B and C — the single-PR invariant still needs existing_pr detection and explicit agent guidance.
Related
Summary
The current Autoloop workflow has a "single long-running branch, single draft PR, per program" invariant stated in prose but not defended in code or config. gh-aw's default safe-outputs behavior silently auto-suffixes branch names to avoid collisions, which breaks the invariant on the second iteration of every program (the moment the first PR merges and the branch collides with itself). The result is a growing stack of draft PRs per program — one new PR per accepted iteration — instead of a single accumulating PR.
This is actually three overlapping problems and the fix needs three coordinated pieces.
Observed failure mode
Without the fix, after the first iteration's PR merges:
create-pull-requestsafe-output appends a random hex salt:autoloop/{program}-8724e9f9.Fix — three coordinated pieces
A. Config:
preserve-branch-name: truein safe-outputsThe
create-pull-requestsafe-output needs explicit opt-in to preserve the agent-chosen branch name. Without this, the framework appends-<hex>on every run to avoid collisions — which defeats the whole single-branch model.```diff
safe-outputs:
create-pull-request:
draft: true
title-prefix: "[Autoloop] "
labels: [automation, autoloop]
protected-files: fallback-to-issue
push-to-pull-request-branch:
target: "*"
title-prefix: "[Autoloop] "
```
max: 1on both, because the invariant is one safe-output of either kind per iteration — never a create+create or a create+push in the same run.B. Scheduler: emit `existing_pr` and `head_branch` to `autoloop.json`
The scheduler (once extracted — see sibling issue #34) should query the GitHub API for the open PR matching the program's branch and expose two new fields:
existing_pr: PR number of the open draft PR forautoloop/{program-name}, ornullif none.head_branch: The canonical branch name, always exactlyautoloop/{program-name}.The lookup should be tolerant of legacy framework-suffixed branches (e.g.,
autoloop/{name}-[0-9a-f]{8}or-[0-9a-f]{16}) so existing installations upgrading to the new scheduler find their in-flight PRs rather than opening a second one:```python
Strategy 1: exact canonical name
prs = gh_api(f"/repos/{repo}/pulls?head={owner}:autoloop/{program}&state=open")
Strategy 2 fallback: framework-suffixed branch OR title-prefix match
if not prs:
for pr in gh_api(f"/repos/{repo}/pulls?state=open&per_page=100"):
head = pr["head"]["ref"]
if re.fullmatch(rf"autoloop/{re.escape(program)}(-[0-9a-f]{{6,40}})?", head):
prs = [pr]
break
if pr["title"].startswith(f"[Autoloop: {program}]"):
prs = [pr]
break
```
Both fields go into
/tmp/gh-aw/autoloop.jsonso the agent's prompt can reference them deterministically.C. Prose: explicit "no suffixes" warnings + Common Mistakes section
The current prompt tells the agent to "use the canonical branch name" but leaves room for error. Agents have been observed to generate suffixed branch names anyway, so several explicit reinforcements help:
```markdown
And a new "Common Mistakes to Avoid" section near the end:
```markdown
Common Mistakes to Avoid
And in Step 5 (the accept flow), add:
```markdown
Why all three pieces are needed together
preserve-branch-name: truekeeps the name exact but the agent still callscreate_pull_requestwhen it should push to the existing PR — the second create call fails loudly because the branch already exists.existing_prbut the framework still appends-<hex>to its chosen branch name the second time the agent bypasses and callscreate_pull_requestanyway.All three close the same loop from different angles and the invariant holds only when all three are present.
Upstream bug that compounds this
gh-aw's
create_pull_request.cjshas a separate upstream bug wherepreserve-branch-name: trueis silently bypassed when the remote branch already exists (collision handler unconditionally appends a hex salt, ignoring the flag). Filed as github/gh-aw#27454. Fixing that upstream removes part of the pain here but doesn't replace pieces B and C — the single-PR invariant still needsexisting_prdetection and explicit agent guidance.Related
existing_prlookup adds ~50 lines to the scheduler and is best done in the extracted script.