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
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)
- Merge, never rebase — preserves fork history/divergence (skill
fork-upstream-sync). - 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 tomax_per_tick = 2forks, oldest-attempt-first so the budget rotates fairly. - 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. - Conflicted merges go to Claude Code —
resolve_conflicts: true. The prompt encodes thefork-upstream-syncrules (merge hunks by hand, never wholesale--ours/--theirs, strip UTF-8 BOM, run the repo's typecheck+tests before committing) plus themerge-reconcilerimpartiality 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. - Clean merges get a quality pass —
ai_fix_after_merge: trueruns one Claude Code pass over the merged files and commitsfix: 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. - Push path: owner PAT first, App token second — the App has
contents:writebut NOTworkflows: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()). - 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. - 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 topre_merge_shaand Discord is notified. Never reverts when a human pushed on top; never applies to the protected/PR path; watch stops afterverify_ci_max_age_h = 6h. - Discord: sync events go to the same
pr-agent-opswebhook as the run reports (synced / PR opened / reverted / skipped-once). merge_prPAT 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-Syncsection (config, discovery, compare, merge, conflict-resolve, push, PR path, verify/revert, orchestrator) + STEP 0 inmain()+ 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_compileclean.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 fullowner/repo:branchhead 404s.
Out of scope
- Syncing non-default branches; pushing fork→upstream; scheduled rebase option.