Skip to content

Commit fc04bbc

Browse files
authored
fix: preserve active agent PR heads during branch preparation (#509)
1 parent 1d8af36 commit fc04bbc

10 files changed

Lines changed: 364 additions & 20 deletions

‎.github/workflows/autoloop.lock.yml‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/autoloop.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ no setup.
180180

181181
After switching/synchronizing branches, use the absolute pinned Python executable
182182
recorded in the read-only `$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_manifest.json`
183-
to run `$RUNNER_TEMP/gh-aw/actions/tsb_provision_agent_runtime.py` with
183+
with `-I` to run `$RUNNER_TEMP/gh-aw/actions/tsb_provision_agent_runtime.py` with
184184
`--selection "$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_selection.json" --repo-root "$GITHUB_WORKSPACE"`,
185185
then repeat with `--check-only` for a bounded refresh-then-check sequence.
186186
Never source a writable `.env`, substitute the branch-owned helper, or restage
@@ -513,18 +513,22 @@ automatic rejection-plateau skip.
513513

514514
### Step 3: Implement
515515

516-
1. Synchronize the canonical branch locally with the repository default branch.
516+
1. Prepare the canonical branch locally, without refreshing active PRs from main.
517517
Read `head_branch` from `/tmp/gh-aw/autoloop.json` into `branch`, determine
518518
`default_branch` from repository metadata, then run the trusted helper:
519519

520520
```bash
521-
bash .github/workflows/scripts/sync_automation_branch.sh "$branch" "$default_branch"
521+
bash "$RUNNER_TEMP/gh-aw/actions/sync_automation_branch.sh" "$branch" "$default_branch" \
522+
"$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_selection.json"
522523
```
523524

524-
The helper checks out the existing remote tip and merges the base, using a
525-
fast-forward when possible. It preserves every existing branch commit and
526-
never publishes. If it reports a conflict, stop and record the conflict for
527-
a focused repair. Do not rebase, reset a divergent branch, force-push, or
525+
The trusted helper verifies current PR state. An active PR resumes its exact
526+
remote head without merging main. Only verified no-open-PR reuse refreshes
527+
the base while preserving history, including after squash merges; a new
528+
branch starts at current main. Stop on missing/stale evidence or conflicts;
529+
never substitute the branch-owned helper. If an active PR genuinely requires
530+
a base update, report it for separate authorized coordination, not as part
531+
of the task patch. Do not bypass protected-file checks, rebase, reset a divergent branch, force-push, or
528532
invoke `git push` from the agent; all publication uses safe outputs.
529533
2. Make the proposed changes to the target files only.
530534
3. **Respect the program constraints**: do not modify files outside the target list.

‎.github/workflows/goal.lock.yml‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/goal.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ no setup.
187187

188188
After switching/synchronizing branches, use the absolute pinned Python executable
189189
recorded in the read-only `$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_manifest.json`
190-
to run `$RUNNER_TEMP/gh-aw/actions/tsb_provision_agent_runtime.py` with
190+
with `-I` to run `$RUNNER_TEMP/gh-aw/actions/tsb_provision_agent_runtime.py` with
191191
`--selection "$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_selection.json" --repo-root "$GITHUB_WORKSPACE"`,
192192
then repeat with `--check-only` for a bounded refresh-then-check sequence.
193193
Never source a writable `.env`, substitute the branch-owned helper, or restage
@@ -296,15 +296,21 @@ The branch name is always exactly the scheduler-provided `selected.branch`.
296296
Never add suffixes, hashes, run IDs, timestamps, or random tokens. Never let the
297297
framework auto-generate a branch name.
298298

299-
Synchronize the branch locally before making changes. Read `selected.branch`
299+
Prepare the branch locally before making changes. Read `selected.branch`
300300
into `branch`, determine `default_branch` from repository metadata, then run:
301301

302302
```bash
303-
bash .github/workflows/scripts/sync_automation_branch.sh "$branch" "$default_branch"
303+
bash "$RUNNER_TEMP/gh-aw/actions/sync_automation_branch.sh" "$branch" "$default_branch" \
304+
"$RUNNER_TEMP/gh-aw/actions/tsb_agent_runtime_selection.json"
304305
```
305306

306-
This preserves remote history and merges the base without rewriting existing
307-
commits. Stop on conflicts and record the focused repair needed. Never rebase,
307+
The trusted helper verifies current PR state. An active PR resumes its exact
308+
remote head without merging main. Only verified no-open-PR reuse refreshes the
309+
base while preserving history, including after squash merges; a new branch
310+
starts at current main. Stop on missing/stale evidence or conflicts. Never use
311+
the branch-owned helper. If an active PR genuinely requires a base update,
312+
report that separately for authorized branch coordination; do not mix it into
313+
the repair or bypass protected-file checks. Never rebase,
308314
force-push, or run `git push` from the agent. Publish through exactly one
309315
`push-to-pull-request-branch` request for an existing PR, or one
310316
`create-pull-request` request when no canonical open PR exists.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
"""Choose resume versus base refresh from trusted selection and live PR evidence."""
3+
4+
import argparse
5+
import json
6+
from pathlib import Path
7+
import re
8+
import subprocess
9+
import sys
10+
11+
12+
def positive_integer(value):
13+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
14+
15+
16+
def selected_pr(selection, branch):
17+
if not isinstance(selection, dict) or selection.get("error"):
18+
raise ValueError("Branch preparation requires successful scheduler evidence")
19+
selected = selection.get("selected")
20+
if isinstance(selected, dict):
21+
selected_branch, record = selected.get("branch"), selected
22+
elif isinstance(selected, str) and selected:
23+
selected_branch, record = selection.get("head_branch"), selection
24+
else:
25+
raise ValueError("Branch preparation requires selected work")
26+
if selected_branch != branch or "existing_pr" not in record:
27+
raise ValueError("Selection must identify this exact branch and its PR state")
28+
number = record["existing_pr"]
29+
if number is not None and not positive_integer(number):
30+
raise ValueError("Selected PR must be a positive integer or explicit null")
31+
return number
32+
33+
34+
def github_open_prs(repository, branch):
35+
result = subprocess.run(
36+
["gh", "api", "--method", "GET", f"repos/{repository}/pulls",
37+
"-f", "state=open", "-f", f"head={repository.split('/')[0]}:{branch}",
38+
"-F", "per_page=100"],
39+
check=True, capture_output=True, text=True, timeout=30,
40+
)
41+
return json.loads(result.stdout)
42+
43+
44+
def branch_mode(selection, branch, base, repository, fetch_open_prs=github_open_prs):
45+
number = selected_pr(selection, branch)
46+
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
47+
raise ValueError("A valid repository identity is required")
48+
pull_requests = fetch_open_prs(repository, branch)
49+
if not isinstance(pull_requests, list) or len(pull_requests) > 1:
50+
raise ValueError("Expected an unambiguous open-PR list for this branch")
51+
for pull_request in pull_requests:
52+
if not isinstance(pull_request, dict):
53+
raise ValueError("Invalid open-PR evidence")
54+
head = pull_request.get("head")
55+
target = pull_request.get("base")
56+
if (not positive_integer(pull_request.get("number")) or
57+
pull_request.get("state") != "open" or not isinstance(head, dict) or
58+
head.get("ref") != branch or not isinstance(head.get("repo"), dict) or
59+
head["repo"].get("full_name") != repository or not isinstance(target, dict) or
60+
target.get("ref") != base or not isinstance(target.get("repo"), dict) or
61+
target["repo"].get("full_name") != repository):
62+
raise ValueError("Open-PR evidence does not match the canonical repository branch")
63+
live_number = pull_requests[0]["number"] if pull_requests else None
64+
if live_number != number:
65+
raise ValueError("Scheduler PR selection is stale; stop and select again on a later run")
66+
return "resume" if live_number is not None else "refresh-base"
67+
68+
69+
def main(argv=None):
70+
parser = argparse.ArgumentParser(description=__doc__)
71+
parser.add_argument("--selection", type=Path, required=True)
72+
parser.add_argument("--branch", required=True)
73+
parser.add_argument("--base", required=True)
74+
parser.add_argument("--repo", required=True)
75+
args = parser.parse_args(argv)
76+
try:
77+
selection = json.loads(args.selection.read_text())
78+
print(branch_mode(selection, args.branch, args.base, args.repo))
79+
return 0
80+
except (OSError, ValueError, subprocess.SubprocessError) as error:
81+
print(f"Branch preparation blocked: {error}", file=sys.stderr)
82+
return 1
83+
84+
85+
if __name__ == "__main__":
86+
sys.exit(main())

‎.github/workflows/scripts/provision_agent_runtime.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ def stage_runtime(actions_dir, selection, report):
108108
source = Path(__file__).resolve()
109109
shutil.copy2(source, actions_dir / "tsb_provision_agent_runtime.py")
110110
shutil.copy2(source.with_name("tsb_runtime_harness.cjs"), actions_dir / "tsb_runtime_harness.cjs")
111+
for helper in ("sync_automation_branch.sh", "automation_branch_state.py"):
112+
shutil.copy2(source.with_name(helper), actions_dir / helper)
111113
(actions_dir / "tsb_agent_runtime_selection.json").write_text(json.dumps(selection, indent=2) + "\n")
112114
(actions_dir / "tsb_agent_runtime_manifest.json").write_text(
113115
json.dumps({"schema_version": 1, **report}, indent=2) + "\n")

0 commit comments

Comments
 (0)