Files
pr-agent-server/.hermes/plans/2026-09-21-upstream-auto-sync.md
T
asepharyana c48adea6c3 feat(worker): upstream fork auto-sync — pull+merge fork repos hourly
Merge new upstream (parent) commits into every fork in the App installation,
gated by a per-repo interval (default 1h), inside the existing 5-minute tick
(STEP 0, max 2 forks/tick, oldest-first).

- Conflicted merges are resolved by Claude Code (merge-reconciler rules:
  never wholesale --ours/--theirs, verify with the repo's own
  typecheck+tests, commit --no-edit; Claude never pushes — harness does).
- Clean merges get a single Claude Code quality pass commit.
- Push path: owner PAT (gh CLI) first — the App lacks workflows:write and a
  workflows-touching merge is rejected for the App token; App token fallback.
- Protected default branch: detected from the push result (GH006 /
  required-status-check) → upstream-sync-<ts> branch + PR through the normal
  pipeline; duplicate open sync PRs are skipped.
- CI safety: after a direct push, ticks verify the fork CI at our merge sha;
  red CI at OUR merge (still the tip) → sha-guarded force-revert to
  pre-merge sha + Discord notify; never reverts foreign commits.
- Discord: synced / PR opened / reverted / skipped-once on pr-agent-ops.
- merge_pr gains the same PAT fallback (a PR merge touching workflows is a
  workflow-file push).
- CLI: --sync-status, --sync-only <repo> [--dry].
- Tests: scripts/test_pr_queue_sync.py (46 assertions, monkeypatched, no
  network); py_compile clean.
- Plan: .hermes/plans/2026-09-21-upstream-auto-sync.md
2026-09-21 17:04:39 +07:00

5.6 KiB

Upstream Auto-Sync — Auto Pull + Merge for Fork Repos

Status: implemented in scripts/pr-queue-worker.py (2026-09-21). User decisions: interval 1 hour, sync PRs do get the Claude Code auto-fix.

Goal

For every repo the GitHub App is installed on whose GitHub metadata says fork: true, pull the commits the upstream (parent) repo has that the fork lacks and MERGE them into the fork's default branch — with conflict resolution, CI verification and deduped notifications. No new cron/service: the existing pr-queue-worker.py tick (every 5 min) runs it, gated by a per-repo interval.

Fork state at build time (live)

Fork Parent To merge Fork divergence
asepharyana/shiro-neko zakirkun/shiro-neko 4 26
asepharyana/hermes-agent-mission-control sharbelxyz/hermes-agent-mission-control 0 8

Both default branches are unprotected; the real shiro-neko merge conflicts in 14 files (src/tools.ts, src/ui/App.tsx, src/tests…).

Design decisions (with reasons)

  1. Merge, never rebase — preserves fork history/divergence (skill fork-upstream-sync).
  2. Inside pr-queue-worker.py — the worker already owns the atomic cron lock, installation tokens and Discord plumbing; the sync is STEP 0 of the same tick, bounded to max_per_tick = 2 forks, oldest-attempt-first so the budget rotates fairly.
  3. 1h interval per repo — the compare API is checked every tick (cheap), a merge attempt only when the interval elapsed AND the upstream tip changed (last_attempt_sha), so a failed/skipped sync is not retried every 5 minutes.
  4. Conflicted merges go to Claude Code — resolve_conflicts: true. The prompt encodes the fork-upstream-sync rules (merge hunks by hand, never wholesale --ours/--theirs, strip UTF-8 BOM, run the repo's typecheck+tests before committing) plus the merge-reconciler impartiality contract (classify every hunk: disjoint-intent / same-question-different-answer / superseded; change nothing outside conflict markers; report every decision). Claude never pushes — the harness pushes.
  5. Clean merges get a quality pass — ai_fix_after_merge: true runs one Claude Code pass over the merged files and commits fix: auto-fix code quality [skip ci]. On the PR path the normal worker AI fix applies (user decision), so sync PRs are treated like any other PR.
  6. Push path: owner PAT first, App token second — the App has contents:write but NOT workflows:write, and the shiro-neko merge touches .github/workflows/*, so an App-token push is rejected. Clones use the App token; pushes use the gh CLI PAT (_fetch_gh_token()).
  7. Protected branch → PR path — App tokens get 403 (not 404) on the branch-protection endpoint, so protection is detected from the PUSH result (GH006 / "protected branch" / required-status-check markers) and falls back to pushing a upstream-sync-<ts> branch + opening a PR that the normal pipeline finishes. Duplicate PRs are avoided by scanning open PRs for that head prefix.
  8. CI verify with sha-guarded auto-revert — verify_ci: true. After a direct push, later ticks check the fork's CI at our merge sha; if it is still the tip and CI failed, the branch is force-pushed back to pre_merge_sha and Discord is notified. Never reverts when a human pushed on top; never applies to the protected/PR path; watch stops after verify_ci_max_age_h = 6h.
  9. Discord: sync events go to the same pr-agent-ops webhook as the run reports (synced / PR opened / reverted / skipped-once).
  10. merge_pr PAT fallback — a PR merge that changes workflow files is also a workflow-file push, so a 403 on the App merge retries with the PAT.

Scope

  • scripts/pr-queue-worker.py — new # Upstream Fork Auto-Sync section (config, discovery, compare, merge, conflict-resolve, push, PR path, verify/revert, orchestrator) + STEP 0 in main() + CLI flags.
  • scripts/test_pr_queue_sync.py — new: 46 assertions over the orchestration.
  • State: /tmp/pr-queue-sync-state.json; workdirs /tmp/pr-queue-sync-work/.
  • No changes to server/ (Bun), systemd units or the cron list.

State shape

{"owner/fork": {
  "last_sync_ts": 0, "last_attempt_sha": "", "last_merged_upstream_sha": "",
  "skip_reason": "", "notified": false,
  "pending_verify": {"sha": "", "pre_merge_sha": "", "branch": "main", "pushed_at": 0}
}}

CLI (manual/dev)

python3 scripts/pr-queue-worker.py --sync-status
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko

--dry runs the whole flow (clone, merge, Claude conflict resolution) and stops before pushing, leaving the prepared workdir in /tmp/pr-queue-sync-work/<repo>.

Verification

  • python3 -m py_compile clean.
  • python3 scripts/test_pr_queue_sync.py → 46/46 (gating, clean merge, conflict resolution, failed resolution skip+dedupe, protected→PR, CI green/red/foreign- commit/running, fork discovery, push-error classification, config override).
  • Live dry run against the real fork: clone + upstream fetch + conflicted merge + Claude Code resolution, verified with the repo's own bun run typecheck/bun test.
  • Live production: the 5-minute cron picks it up on the next tick after the push (the cron wrapper ff-only pulls this repo and execs the repo copy).
  • Regression guard for the bug the dry run caught: compare head ref must be {owner}:{branch} — a full owner/repo:branch head 404s.

Out of scope

  • Syncing non-default branches; pushing fork→upstream; scheduled rebase option.