Skip to content

Enforce the single-PR-per-program invariant: preserve-branch-name, existing_pr/head_branch pre-step outputs, explicit 'no suffixes' prose #35

@mrjf

Description

@mrjf

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: 2
  • max: 1
    push-to-pull-request-branch:
    target: "*"
    title-prefix: "[Autoloop] "
  • max: 2
  • max: 1
    ```

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions