From b4c665e902aae7ca42ca0ac8a1e72e3d04bcba3e Mon Sep 17 00:00:00 2001 From: Paul Dittrich Date: Fri, 16 Oct 2026 13:35:57 +0200 Subject: [PATCH 1/4] ci(fork): upstream sync repairs itself in rounds and continues a red sync Copilot now gets up to three sessions, each started with the red verify reports of the one before, a wait timeout long enough for verify, and a larger per-session budget. A sync that stays red is continued the next day from its open PR instead of being redone from fork with a bigger backlog. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-upstream-sync.sh | 53 ++++- .github/workflows/fork-upstream-sync.yml | 245 +++++++++++++++++------ FORK.md | 12 +- 3 files changed, 236 insertions(+), 74 deletions(-) diff --git a/.github/scripts/fork-upstream-sync.sh b/.github/scripts/fork-upstream-sync.sh index d202df1ed007b..1fd47c530b8ab 100755 --- a/.github/scripts/fork-upstream-sync.sh +++ b/.github/scripts/fork-upstream-sync.sh @@ -5,23 +5,30 @@ # the merge or the agent writes into the checkout changes what it checks. # # Env: FORK_SHA (fork before the merge), TARGET (upstream commit to merge), -# SYNC_DIR (reports and outputs). Run from the repository root. +# SYNC_DIR (reports and outputs), optionally PREV (head of an earlier sync +# attempt that stayed red, to continue from). Run from the repository root. # # merge merge TARGET into the checked-out FORK_SHA, rerere on; lists the -# files left unmerged in $SYNC_DIR/conflicts.txt, exits 1 if any +# files left unmerged in $SYNC_DIR/conflicts.txt, exits 1 if any. +# With PREV it merges FORK_SHA and TARGET into PREV instead, so the +# earlier attempt's resolutions and fixes carry over; writes +# $SYNC_DIR/continued when it did +# fold with PREV: once the merge is committed, rewrites the history to +# FORK_SHA + one merge commit of TARGET holding the same tree # checks install, typecheck, and the tests related to the files both # sides changed or the agent touched; report in checks.md # guards hard rules the result must meet; report in guards.md # audit lines the fork added that the result lost; audit.md. Fails above # AUDIT_MAX_LOST (default 50): a careful merge loses a few to # upstream rewrites, taking upstream's side of a conflict hundreds -# verify checks, guards and audit in one go +# verify fold, then checks, guards and audit in one go # workflows every .github file the sync changed equals upstream's version set -uo pipefail : "${FORK_SHA:?}" "${TARGET:?}" "${SYNC_DIR:?}" mkdir -p "$SYNC_DIR" BASE=$(git merge-base "$FORK_SHA" "$TARGET") +MSG="chore: merge upstream/main ($(git rev-parse --short "$TARGET"))" # The sync's merge commit: first commit on HEAD's first-parent chain after FORK_SHA. merge_commit() { git rev-list --first-parent --reverse "$FORK_SHA..HEAD" | head -1; } @@ -31,7 +38,21 @@ changed() { git diff --name-only --no-renames "$@"; } cmd_merge() { git config rerere.enabled true git config rerere.autoUpdate true - git merge --no-ff --no-edit -m "chore: merge upstream/main ($(git rev-parse --short "$TARGET"))" "$TARGET" >/dev/null 2>&1 + rm -f "$SYNC_DIR/continued" + if [ -n "${PREV:-}" ]; then + # fork's own new commits rarely conflict with the earlier attempt; if they + # do, start fresh from fork rather than hand the agent two merges at once. + git checkout -q --detach "$PREV" + if git merge --no-edit -m "chore: merge fork into the previous upstream sync" "$FORK_SHA" >/dev/null 2>&1; then + echo "$PREV" > "$SYNC_DIR/continued" + echo "Continuing the earlier sync attempt $PREV." + else + git merge --abort + git checkout -q "$FORK_SHA" + echo "fork's new commits conflict with the earlier sync attempt; starting fresh from fork." + fi + fi + git merge --no-ff --no-edit -m "$MSG" "$TARGET" >/dev/null 2>&1 # rerere may have resolved every conflict; `ls-files -u` is the truth, not the exit code. git ls-files -u | cut -f2 | sort -u > "$SYNC_DIR/conflicts.txt" if [ -s "$SYNC_DIR/conflicts.txt" ]; then @@ -42,12 +63,30 @@ cmd_merge() { echo "Merged without conflicts." } -# Files where both sides meet, plus whatever came after the merge commit. +cmd_fold() { + [ -s "$SYNC_DIR/continued" ] || return 0 + git rev-parse -q --verify MERGE_HEAD >/dev/null && return 0 + [ -n "$(git ls-files -u)" ] && return 0 + local m + m=$(merge_commit) + if [ -n "$m" ] && [ "$(git rev-parse "$m^1")" = "$FORK_SHA" ] && + [ "$(git rev-parse "$m^2" 2>/dev/null)" = "$(git rev-parse "$TARGET")" ]; then + return 0 + fi + # Only content that really came through merges of both sides gets folded. + git merge-base --is-ancestor "$FORK_SHA" HEAD && git merge-base --is-ancestor "$TARGET" HEAD || return 0 + git reset -q --soft "$(git commit-tree "HEAD^{tree}" -p "$FORK_SHA" -p "$TARGET" -m "$MSG")" + echo "Folded the continued sync into one merge commit of fork and upstream." +} + +# Files where both sides meet, files the result takes from neither side, and +# whatever came after the merge commit. touched_files() { local m m=$(merge_commit) { comm -12 <(changed "$BASE" "$FORK_SHA" | sort) <(changed "$BASE" "$TARGET" | sort) + comm -12 <(changed "$FORK_SHA" HEAD | sort) <(changed "$TARGET" HEAD | sort) [ -n "$m" ] && changed "$m" HEAD } | sort -u | while read -r f; do [ -n "$(git ls-tree HEAD -- "$f")" ] && printf '%s\n' "$f" @@ -82,9 +121,10 @@ cmd_checks() { case "$extra" in "vp test run"*) extra=${extra#vp test run} ;; *) continue ;; esac extra=${extra//--passWithNoTests/} rel=$(printf '%s\n' "$files" | grep "^$pkg/" | sed "s|^$pkg/||") + # One retry, so a flaky test does not leave the sync for the maintainer. # shellcheck disable=SC2086 step "tests related to $(echo "$rel" | wc -l | tr -d ' ') changed files in $pkg" \ - bash -c 'cd "$1" && shift && vp test related --run --passWithNoTests "$@"' _ "$pkg" $extra $rel + bash -c 'cd "$1" && shift && vp test related --run --passWithNoTests --retry 1 "$@"' _ "$pkg" $extra $rel done return $failed } @@ -163,6 +203,7 @@ cmd_audit() { cmd_verify() { local failed=0 + cmd_fold cmd_checks || failed=1 cmd_guards || failed=1 cmd_audit || failed=1 diff --git a/.github/workflows/fork-upstream-sync.yml b/.github/workflows/fork-upstream-sync.yml index f564d5d26c171..f2b99c4af745a 100644 --- a/.github/workflows/fork-upstream-sync.yml +++ b/.github/workflows/fork-upstream-sync.yml @@ -3,7 +3,11 @@ # # `merge` runs upstream's code and the Copilot CLI, so it holds no token that # can write: it merges, lets Copilot resolve conflicts and fix what broke, and -# hands the result on as a git bundle. `publish` never runs that code: it checks +# hands the result on as a git bundle. Copilot works in rounds: after each +# session the fixed verify runs, and while it is red the next session starts +# with its reports. A sync that stays red is not redone from scratch the next +# day: the next run continues from its open PR, merging fork's and upstream's +# new commits into it, and closes it in favour of the new one. `publish` never runs that code: it checks # the bundle, pushes it as a PR and merges the PR when every check was green, # otherwise leaves it as a draft for the maintainer. The fixed checks live in # .github/scripts/fork-upstream-sync.sh and always run from the fork commit the @@ -25,12 +29,19 @@ on: description: Upstream branch or commit to merge (an older commit merges a backlog in stages) default: main dry_run: - description: Leave the PR as a draft even when everything is green + description: Open no PR and close none; the result is only the run's sync artifact + type: boolean + default: false + fresh: + description: Start from fork, ignoring an open sync PR type: boolean default: false max_ai_credits: - description: Copilot's budget for this run in AI credits (1 credit = $0.01); a large backlog needs more - default: "300" + description: Copilot's budget per session in AI credits (1 credit = $0.01) + default: "600" + rounds: + description: Copilot sessions at most, each one fixing what the previous one's verify found red + default: "3" model: description: Copilot model for this run default: gpt-5.6-sol @@ -50,14 +61,16 @@ jobs: merge: if: github.repository == 'esveo/t3code' runs-on: ubuntu-latest - timeout-minutes: 150 + timeout-minutes: 345 permissions: contents: read + pull-requests: read copilot-requests: write outputs: behind: ${{ steps.upstream.outputs.behind }} fork_sha: ${{ steps.upstream.outputs.fork_sha }} target: ${{ steps.upstream.outputs.target }} + prev_pr: ${{ steps.merge.outputs.prev_pr }} green: ${{ steps.verdict.outputs.green }} bundled: ${{ steps.verdict.outputs.bundled }} steps: @@ -94,6 +107,26 @@ jobs: echo "target=$target" } >> "$GITHUB_OUTPUT" + # The newest open sync PR is an earlier attempt that stayed red (green ones + # merge themselves); continue from it instead of redoing its work. + - name: Find an earlier attempt + if: steps.upstream.outputs.behind != '0' && inputs.fresh != true + env: + GH_TOKEN: ${{ github.token }} + run: | + prev=$(gh pr list --repo "$GITHUB_REPOSITORY" --base fork --state open --json number,headRefName,createdAt \ + --jq '[.[] | select(.headRefName | startswith("sync/upstream-"))] | sort_by(.createdAt) | last // empty | "\(.number) \(.headRefName)"') + [ -n "$prev" ] || { echo "No open sync PR; starting from fork."; exit 0; } + read -r number branch <<< "$prev" + mkdir -p "$SYNC_DIR" + git fetch --no-tags origin "refs/heads/$branch" + gh pr view "$number" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "$SYNC_DIR/previous-pr.md" + echo "Continuing #$number ($branch)." + { + echo "PREV=$(git rev-parse FETCH_HEAD)" + echo "PREV_PR=$number" + } >> "$GITHUB_ENV" + - name: Restore conflict resolutions if: steps.upstream.outputs.behind != '0' uses: actions/cache/restore@v4 @@ -121,6 +154,11 @@ jobs: else echo "conflicts=true" >> "$GITHUB_OUTPUT" fi + if [ -s "$SYNC_DIR/continued" ]; then + echo "prev_pr=$PREV_PR" >> "$GITHUB_OUTPUT" + else + echo "PREV=" >> "$GITHUB_ENV" + fi - name: Verify the plain merge id: plain @@ -145,70 +183,136 @@ jobs: id: agent if: steps.merge.outputs.conflicts == 'true' || steps.plain.outcome == 'failure' continue-on-error: true - timeout-minutes: 90 + timeout-minutes: 300 env: COPILOT_GITHUB_TOKEN: ${{ github.token }} - MAX_AI_CREDITS: ${{ inputs.max_ai_credits || '300' }} + MAX_AI_CREDITS: ${{ inputs.max_ai_credits || '600' }} + ROUNDS: ${{ inputs.rounds || '3' }} MODEL: ${{ inputs.model || 'gpt-5.6-sol' }} + UPSTREAM_REF: ${{ inputs.upstream_ref || env.DEFAULT_UPSTREAM_REF }} + # How long the CLI waits for a command it moved to the background + # before it ends the session (default 600 s). verify takes 20+ minutes, + # and the default ended every session that ran it. + COPILOT_TASK_WAIT_TIMEOUT_SECONDS: "3600" run: | npm install -g @github/copilot - cat > "$RUNNER_TEMP/prompt.md" <\`, \`git log $FORK_SHA..$TARGET -- \`). - Do not hand-merge pnpm-lock.yaml: resolve the package.json files, take - the fork's lockfile (\`git checkout --ours pnpm-lock.yaml\`), regenerate - it with \`vp install --no-frozen-lockfile\` and commit it. - 2. Commit the merge with \`git commit --no-edit\`. It must stay a merge - commit of $FORK_SHA and $TARGET: never rebase, reset, squash or amend - past it. Commit every later fix as a separate commit on top. - 3. Verify with \`bash <(git show $FORK_SHA:$SCRIPT) verify\`. It installs, - typechecks, runs the tests related to the files both sides changed and - checks the merge's shape. Its reports land in $SYNC_DIR: checks.md, - checks.log, guards.md and audit.md. audit.md lists lines the fork added - that the result lost; restore every one that belongs to a fork feature, - and adapt it to upstream's new code where upstream rewrote it. - Fix what fails and verify again until it passes. - + rules() { + cat < typecheck\`, + \`cd && vp test run \`), and run verify at most once, at + the end, with its output redirected to a file. The workflow runs verify + after your session anyway and, while it is red, starts another session + with its reports (this is round $1 of $ROUNDS). audit.md lists lines the + fork added that the result lost; restore every one that belongs to a + fork feature, adapted to upstream's new code where upstream rewrote it. + + When done or stuck, and early enough to still have budget, write $SYNC_DIR/summary.md: the conflicts and how you resolved each, the fixes - you made, what remains open and the options you see. If you cannot - finish with confidence (for example upstream rebuilt the code a fork - feature depends on) or verify stays red after honest attempts, write - "blocked" to $SYNC_DIR/status and commit what you have, conflict - markers included, so the maintainer can pick it up from there. + you made, what remains open and the options you see (later rounds update + it). Only if you cannot finish with confidence (for example upstream + rebuilt the code a fork feature depends on) write "blocked" to + $SYNC_DIR/status: that ends the rounds and leaves the sync to the + maintainer. Commit what you have either way, conflict markers included. The runner has limited memory: run typecheck, tests, installs and builds one at a time, never in parallel with each other, and never from several sub-agents at once. EOF + } + prompt() { + cat </dev/null || true + echo + head -c 3000 "$SYNC_DIR/audit.md" 2>/dev/null || true + echo + echo "Failing lines from $SYNC_DIR/checks.log (read the log for context):" + sed 's/\x1b\[[0-9;]*m//g' "$SYNC_DIR/checks.log" 2>/dev/null | + grep -E '^ *FAIL |error TS[0-9]|ERR_PNPM_|^error: ' | sort -u | head -80 || true + echo + else + if [ -n "${PREV:-}" ]; then + echo "This run continues an earlier sync attempt that stayed red (PR #$PREV_PR):" + echo "HEAD is that attempt with fork's newer commits merged in. Build on its work" + echo "instead of redoing it; its description, with that session's summary and red" + echo "checks, is in $SYNC_DIR/previous-pr.md. verify folds the history into one" + echo "merge commit of fork and upstream by itself." + echo + fi + if [ -s "$SYNC_DIR/conflicts.txt" ]; then + echo "The merge of upstream stopped with conflicts in:" + sed 's/^/- /' "$SYNC_DIR/conflicts.txt" + echo + echo "Resolve every conflict. Take upstream's change and keep the fork's" + echo "feature working on top of it. Read both sides and the commits behind" + echo "them (\`git log $FORK_SHA -- \`, \`git log $FORK_SHA..$TARGET -- \`)." + echo "Do not hand-merge pnpm-lock.yaml: resolve the package.json files, take" + echo "the fork's lockfile (\`git checkout --ours pnpm-lock.yaml\`), regenerate" + echo "it with \`vp install --no-frozen-lockfile\` and commit it." + else + echo "The merge is committed but its checks failed; see $SYNC_DIR/checks.md," + echo "guards.md, audit.md and checks.log. Fix what fails." + fi + echo + fi + rules "$1" + } git rev-parse HEAD > "$SYNC_DIR/head-before" git status --porcelain > "$SYNC_DIR/status-before" - copilot -p "$(cat "$RUNNER_TEMP/prompt.md")" \ - --model "$MODEL" \ - --no-ask-user --allow-all-tools --allow-all-paths \ - --deny-tool 'shell(git push)' --deny-tool 'shell(gh)' \ - --secret-env-vars=COPILOT_GITHUB_TOKEN \ - --max-ai-credits "$MAX_AI_CREDITS" \ - --share "$SYNC_DIR/transcript.md" \ - --usage-output-file "$SYNC_DIR/usage.json" + round=1 + while :; do + prompt "$round" > "$RUNNER_TEMP/prompt-$round.md" + timeout 70m copilot -p "$(cat "$RUNNER_TEMP/prompt-$round.md")" \ + --model "$MODEL" \ + --no-ask-user --allow-all-tools --allow-all-paths \ + --deny-tool 'shell(git push)' --deny-tool 'shell(gh)' \ + --secret-env-vars=COPILOT_GITHUB_TOKEN \ + --max-ai-credits "$MAX_AI_CREDITS" \ + --share "$SYNC_DIR/transcript-$round.md" \ + --usage-output-file "$SYNC_DIR/usage-$round.json" || + echo "::warning::Copilot session $round ended with exit code $?" + # A Copilot that never started (model not enabled, auth refused) leaves + # only the conflicted merge; "Check that Copilot ran" fails the run. + if [ "$round" = 1 ] && [ "$(git rev-parse HEAD)" = "$(cat "$SYNC_DIR/head-before")" ] && + [ "$(git status --porcelain)" = "$(cat "$SYNC_DIR/status-before")" ]; then + exit 1 + fi + # Unresolved conflicts go in with their markers, for the next round. + rm -f .git/index.lock + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git add -A && git commit -q --no-verify --no-edit + elif [ -n "$(git status --porcelain)" ]; then + git add -A && git commit -q --no-verify -m "chore: changes the upstream sync left uncommitted" + fi + if bash <(git show "$FORK_SHA:$SCRIPT") verify; then + echo "$(git rev-parse HEAD) green" > "$SYNC_DIR/verified" + break + fi + echo "$(git rev-parse HEAD) red" > "$SYNC_DIR/verified" + [ "$(cat "$SYNC_DIR/status" 2>/dev/null)" != blocked ] && [ "$round" -lt "$ROUNDS" ] || break + round=$((round + 1)) + done # Everything below runs even when the runner cancelled the Copilot step (as # it did once after 8 minutes, without a manual cancel), so the work done @@ -241,11 +345,19 @@ jobs: git commit -m "chore: changes the upstream sync left uncommitted" fi + # Reuses the rounds' last verify when nothing changed since. - name: Verify the agent's result id: fixed if: "!cancelled() && steps.agent.outcome != 'skipped' && steps.ran.outcome != 'failure'" continue-on-error: true - run: bash <(git show "$FORK_SHA:$SCRIPT") verify + run: | + read -r sha verdict 2>/dev/null < "$SYNC_DIR/verified" || true + if [ "${sha:-}" = "$(git rev-parse HEAD)" ]; then + echo "Verified in the rounds above: $verdict" + [ "$verdict" = green ] + else + bash <(git show "$FORK_SHA:$SCRIPT") verify + fi - name: Verdict id: verdict @@ -277,7 +389,7 @@ jobs: publish: needs: merge # Also after a failed merge job, as long as it bundled a result. - if: "!cancelled() && needs.merge.outputs.bundled == 'true'" + if: "!cancelled() && needs.merge.outputs.bundled == 'true' && inputs.dry_run != true" runs-on: ubuntu-latest permissions: contents: read @@ -286,7 +398,7 @@ jobs: FORK_SHA: ${{ needs.merge.outputs.fork_sha }} TARGET: ${{ needs.merge.outputs.target }} GREEN: ${{ needs.merge.outputs.green }} - DRY_RUN: ${{ inputs.dry_run }} + PREV_PR: ${{ needs.merge.outputs.prev_pr }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: Checkout fork @@ -329,11 +441,12 @@ jobs: [ "$GREEN" = true ] && [ "$CHECK_OK" = true ] && ready=true { echo "Merges upstream's ${{ inputs.upstream_ref || env.DEFAULT_UPSTREAM_REF }} up to [\`$short\`](https://github.com/pingdotgg/t3code/commit/$TARGET) into \`fork\` ($(git rev-list --count "$FORK_SHA..$TARGET") new commits). [Sync run]($RUN_URL)" + [ -n "$PREV_PR" ] && echo "Continues #$PREV_PR, which stayed red." echo if [ "$ready" = true ]; then echo "Everything is green; the sync merges this itself." else - echo "@$MAINTAINER this sync needs you: see the red items below and the run's \`sync\` artifact (Copilot's transcript)." + echo "@$MAINTAINER this sync needs you: see the red items below and the run's \`sync\` artifact (Copilot's transcripts). Fixes pushed to this branch carry over: tomorrow's sync continues from it." fi if [ -s "$SYNC_DIR/conflicts.txt" ]; then echo @@ -345,25 +458,29 @@ jobs: echo "## Copilot's summary" echo cat "$SYNC_DIR/summary.md" - elif [ -s "$SYNC_DIR/transcript.md" ]; then + elif transcript=$(ls "$SYNC_DIR"/transcript-*.md 2>/dev/null | sort -V | tail -1) && [ -s "$transcript" ]; then echo echo "## Copilot's last words" echo - echo "Copilot wrote no summary, most likely because its budget ran out. The end of its transcript:" + echo "Copilot wrote no summary, most likely because its budget ran out. The end of its last transcript:" echo awk '/^### Copilot$/ { buf = ""; keep = 1; next } /^### / { keep = 0 } keep { buf = buf $0 "\n" } END { printf "%s", buf }' \ - "$SYNC_DIR/transcript.md" | sed '/^---$/,$d' | sed 's/^/> /' + "$transcript" | sed '/^---$/,$d' | sed 's/^/> /' fi for f in checks guards audit; do [ -s "$SYNC_DIR/$f.md" ] && { echo; cat "$SYNC_DIR/$f.md"; } done } | head -c 60000 > "$RUNNER_TEMP/body.md" draft=() - [ "$ready" = true ] && [ "$DRY_RUN" != true ] || draft=(--draft) + [ "$ready" = true ] || draft=(--draft) url=$(gh pr create --base fork --head "$branch" "${draft[@]}" \ --title "chore: merge upstream/${{ inputs.upstream_ref || env.DEFAULT_UPSTREAM_REF }} ($short)" --body-file "$RUNNER_TEMP/body.md") echo "url=$url" >> "$GITHUB_OUTPUT" echo "ready=$ready" >> "$GITHUB_OUTPUT" + # The attempt this run continued from lives on in this one. + if [ -n "$PREV_PR" ]; then + gh pr close "$PREV_PR" --delete-branch --comment "Continued in $url" + fi # A green sync supersedes older ones; a red one leaves them for comparison. [ "$ready" = true ] || exit 0 for old in $(gh pr list --base fork --state open --json number,headRefName \ @@ -372,14 +489,14 @@ jobs: done - name: Merge the pull request - if: steps.pr.outputs.ready == 'true' && inputs.dry_run != true + if: steps.pr.outputs.ready == 'true' env: GH_TOKEN: ${{ secrets.SYNC_MERGE_TOKEN }} run: gh pr merge "${{ steps.pr.outputs.url }}" --merge --delete-branch # The fork runs none of upstream's workflows; switch off any the merge added. - name: Disable workflows upstream added - if: steps.pr.outputs.ready == 'true' && inputs.dry_run != true + if: steps.pr.outputs.ready == 'true' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/FORK.md b/FORK.md index 50709b76d8b4f..eb30c846857f1 100644 --- a/FORK.md +++ b/FORK.md @@ -62,10 +62,14 @@ disturbing the build people work in all day. `fork-upstream-sync.yml` merges upstream (see below), lets the Copilot CLI resolve conflicts and fix what broke, and lands the result as a PR it merges itself once typecheck, the related tests and its checks against dropped fork - code are green. Otherwise the PR stays open as a draft that mentions Paul, - the maintainer. Run it by hand from the Actions tab; an older upstream commit - as `upstream_ref` merges a large backlog in stages. Rebase feature branches - onto `fork` to pick upstream up. + code are green. Copilot gets up to three sessions, each starting from the + red checks of the one before. A sync still red after that stays open as a + draft that mentions Paul, the maintainer, and the next morning's run + continues from that PR's branch instead of starting over, then closes it: + fixes pushed to a red sync PR carry over. Run it by hand from the Actions + tab (`fresh` ignores an open sync PR, `dry_run` opens no PR); an older + upstream commit as `upstream_ref` merges a large backlog in stages. Rebase + feature branches onto `fork` to pick upstream up. - The fork runs upstream's Orchestrator V2 (`orchestration-v2/`). Build fork features on it; the V1 orchestration is gone. From ace0ecf85736e92496cd652c5564ccb39f59699b Mon Sep 17 00:00:00 2001 From: Paul Dittrich Date: Fri, 16 Oct 2026 13:57:47 +0200 Subject: [PATCH 2/4] ci(fork): upstream sync runs weekly with a larger budget and gpt-6.1-sol The sync now runs on Mondays (04:00 UTC, 06:00 Berlin in summer, 05:00 in winter), so each run can spend more: - 1500 AI credits per Copilot session instead of 600, and sessions of up to 80 minutes instead of 70. - Still 3 rounds per run: a round is up to 80 min of Copilot plus up to 30 min of verify, so three fit into the job's 355 minutes (GitHub-hosted jobs stop at 360). The loop skips a round that would not finish in time. - A red run that Copilot did not mark blocked dispatches the next run, which continues from the draft PR it just opened, up to 3 runs per sync. That gives up to 9 rounds a week without restructuring the job. Worst case is 3 x 3 x 1500 credits = $135 a week, about what 7 daily runs at 3 x 600 could cost before. - Model gpt-6.1-sol (GA in Copilot since 2026-09-29). The org's model list of 16 Oct does not offer it yet, so the run falls back to gpt-5.6-sol with a warning until an admin enables it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fork-upstream-sync.yml | 70 +++++++++++++++++++----- FORK.md | 21 +++---- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/.github/workflows/fork-upstream-sync.yml b/.github/workflows/fork-upstream-sync.yml index f2b99c4af745a..369cd9182f974 100644 --- a/.github/workflows/fork-upstream-sync.yml +++ b/.github/workflows/fork-upstream-sync.yml @@ -5,9 +5,11 @@ # can write: it merges, lets Copilot resolve conflicts and fix what broke, and # hands the result on as a git bundle. Copilot works in rounds: after each # session the fixed verify runs, and while it is red the next session starts -# with its reports. A sync that stays red is not redone from scratch the next -# day: the next run continues from its open PR, merging fork's and upstream's -# new commits into it, and closes it in favour of the new one. `publish` never runs that code: it checks +# with its reports. A sync that stays red is not redone from scratch: the +# next run continues from its open PR, merging fork's and upstream's new +# commits into it, and closes it in favour of the new one. Such a run is +# dispatched right away (up to MAX_ATTEMPTS runs per sync), since one job +# cannot run longer than 6 hours. `publish` never runs that code: it checks # the bundle, pushes it as a PR and merges the PR when every check was green, # otherwise leaves it as a draft for the maintainer. The fixed checks live in # .github/scripts/fork-upstream-sync.sh and always run from the fork commit the @@ -21,8 +23,9 @@ name: Fork upstream sync on: schedule: - # 06:00 in Germany during summer time, 05:00 in winter (cron runs in UTC). - - cron: "0 4 * * *" + # Mondays, 06:00 in Germany during summer time, 05:00 in winter (cron runs + # in UTC). + - cron: "0 4 * * 1" workflow_dispatch: inputs: upstream_ref: @@ -38,13 +41,16 @@ on: default: false max_ai_credits: description: Copilot's budget per session in AI credits (1 credit = $0.01) - default: "600" + default: "1500" rounds: - description: Copilot sessions at most, each one fixing what the previous one's verify found red + description: Copilot sessions per run at most, each one fixing what the previous one's verify found red default: "3" model: - description: Copilot model for this run - default: gpt-5.6-sol + description: Copilot model for this run (falls back to FALLBACK_MODEL when the org does not offer it) + default: gpt-6.1-sol + attempt: + description: Which run of this sync this is; a red run dispatches the next one up to MAX_ATTEMPTS + default: "1" concurrency: group: fork-upstream-sync @@ -56,12 +62,15 @@ env: MAINTAINER: Pawl-Patrol DEFAULT_UPSTREAM_REF: main SCRIPT: .github/scripts/fork-upstream-sync.sh + FALLBACK_MODEL: gpt-5.6-sol + MAX_ATTEMPTS: 3 jobs: merge: if: github.repository == 'esveo/t3code' runs-on: ubuntu-latest - timeout-minutes: 345 + # GitHub-hosted jobs end after 360 minutes. + timeout-minutes: 355 permissions: contents: read pull-requests: read @@ -183,12 +192,12 @@ jobs: id: agent if: steps.merge.outputs.conflicts == 'true' || steps.plain.outcome == 'failure' continue-on-error: true - timeout-minutes: 300 + timeout-minutes: 330 env: COPILOT_GITHUB_TOKEN: ${{ github.token }} - MAX_AI_CREDITS: ${{ inputs.max_ai_credits || '600' }} + MAX_AI_CREDITS: ${{ inputs.max_ai_credits || '1500' }} ROUNDS: ${{ inputs.rounds || '3' }} - MODEL: ${{ inputs.model || 'gpt-5.6-sol' }} + MODEL: ${{ inputs.model || 'gpt-6.1-sol' }} UPSTREAM_REF: ${{ inputs.upstream_ref || env.DEFAULT_UPSTREAM_REF }} # How long the CLI waits for a command it moved to the background # before it ends the session (default 600 s). verify takes 20+ minutes, @@ -196,6 +205,10 @@ jobs: COPILOT_TASK_WAIT_TIMEOUT_SECONDS: "3600" run: | npm install -g @github/copilot + if [ -s "$SYNC_DIR/models.txt" ] && ! grep -q "^$MODEL enabled" "$SYNC_DIR/models.txt"; then + echo "::warning::The org does not offer $MODEL; using $FALLBACK_MODEL." + MODEL=$FALLBACK_MODEL + fi rules() { cat < "$SYNC_DIR/head-before" git status --porcelain > "$SYNC_DIR/status-before" + # A round is a session of up to 80 minutes plus a verify of up to 30; + # one that cannot finish before the step's timeout is not started. + round_secs=$((110 * 60)) step_secs=$((330 * 60)) round=1 while :; do prompt "$round" > "$RUNNER_TEMP/prompt-$round.md" - timeout 70m copilot -p "$(cat "$RUNNER_TEMP/prompt-$round.md")" \ + timeout 80m copilot -p "$(cat "$RUNNER_TEMP/prompt-$round.md")" \ --model "$MODEL" \ --no-ask-user --allow-all-tools --allow-all-paths \ --deny-tool 'shell(git push)' --deny-tool 'shell(gh)' \ @@ -311,6 +327,10 @@ jobs: fi echo "$(git rev-parse HEAD) red" > "$SYNC_DIR/verified" [ "$(cat "$SYNC_DIR/status" 2>/dev/null)" != blocked ] && [ "$round" -lt "$ROUNDS" ] || break + if [ $((SECONDS + round_secs)) -gt "$step_secs" ]; then + echo "No time left in this job for round $((round + 1)); the next run continues." + break + fi round=$((round + 1)) done @@ -446,7 +466,7 @@ jobs: if [ "$ready" = true ]; then echo "Everything is green; the sync merges this itself." else - echo "@$MAINTAINER this sync needs you: see the red items below and the run's \`sync\` artifact (Copilot's transcripts). Fixes pushed to this branch carry over: tomorrow's sync continues from it." + echo "@$MAINTAINER this sync needs you: see the red items below and the run's \`sync\` artifact (Copilot's transcripts). Fixes pushed to this branch carry over: the next sync continues from it." fi if [ -s "$SYNC_DIR/conflicts.txt" ]; then echo @@ -494,6 +514,26 @@ jobs: GH_TOKEN: ${{ secrets.SYNC_MERGE_TOKEN }} run: gh pr merge "${{ steps.pr.outputs.url }}" --merge --delete-branch + # A red sync that Copilot did not give up on goes on in a new run (a job + # cannot outlast 6 hours), continuing from the PR this run just opened. + - name: Continue in another run + if: steps.pr.outputs.ready != 'true' && fromJSON(inputs.attempt || '1') < fromJSON(env.MAX_ATTEMPTS) + env: + GH_TOKEN: ${{ github.token }} + ATTEMPT: ${{ inputs.attempt || '1' }} + UPSTREAM_REF: ${{ inputs.upstream_ref || env.DEFAULT_UPSTREAM_REF }} + MAX_AI_CREDITS: ${{ inputs.max_ai_credits || '1500' }} + ROUNDS: ${{ inputs.rounds || '3' }} + MODEL: ${{ inputs.model || 'gpt-6.1-sol' }} + run: | + if [ "$(cat "$SYNC_DIR/status" 2>/dev/null)" = blocked ]; then + echo "Copilot marked the sync blocked; leaving it to the maintainer." + exit 0 + fi + gh workflow run fork-upstream-sync.yml --repo "$GITHUB_REPOSITORY" --ref fork \ + -f upstream_ref="$UPSTREAM_REF" -f max_ai_credits="$MAX_AI_CREDITS" \ + -f rounds="$ROUNDS" -f model="$MODEL" -f attempt="$((ATTEMPT + 1))" + # The fork runs none of upstream's workflows; switch off any the merge added. - name: Disable workflows upstream added if: steps.pr.outputs.ready == 'true' diff --git a/FORK.md b/FORK.md index eb30c846857f1..edabbe0bcf946 100644 --- a/FORK.md +++ b/FORK.md @@ -4,7 +4,7 @@ `pingdotgg/t3code`.** Everyone at esveo can use it and work on it; Paul maintains it. It adds the features esveo wants (for example the agent stage in `apps/web/src/components/agentStage/`) on top of upstream, which is merged in -every day. `AGENTS.md` is upstream's guide for its maintainers; follow it for +every week. `AGENTS.md` is upstream's guide for its maintainers; follow it for code and architecture, but these rules win wherever they differ. **The app this fork builds is called "esveo code".** Use that name in replies, @@ -58,18 +58,19 @@ disturbing the build people work in all day. the record of what everyone has been running. - Only merge a feature the user asked to go live. Ask when in doubt — an unfinished branch in `fork` is a broken app for everyone at esveo. -- Upstream reaches `fork` by itself: every morning at 06:00 +- Upstream reaches `fork` by itself: every Monday at 06:00 `fork-upstream-sync.yml` merges upstream (see below), lets the Copilot CLI resolve conflicts and fix what broke, and lands the result as a PR it merges itself once typecheck, the related tests and its checks against dropped fork - code are green. Copilot gets up to three sessions, each starting from the - red checks of the one before. A sync still red after that stays open as a - draft that mentions Paul, the maintainer, and the next morning's run - continues from that PR's branch instead of starting over, then closes it: - fixes pushed to a red sync PR carry over. Run it by hand from the Actions - tab (`fresh` ignores an open sync PR, `dry_run` opens no PR); an older - upstream commit as `upstream_ref` merges a large backlog in stages. Rebase - feature branches onto `fork` to pick upstream up. + code are green. Copilot gets up to three sessions per run, each starting + from the red checks of the one before. A sync still red after that stays + open as a draft that mentions Paul, the maintainer, and a new run starts + right away that continues from that PR's branch instead of starting over, + then closes it, up to three runs per sync: fixes pushed to a red sync PR + carry over, and the next Monday's run continues it too. Run it by hand from + the Actions tab (`fresh` ignores an open sync PR, `dry_run` opens no PR); an + older upstream commit as `upstream_ref` merges a large backlog in stages. + Rebase feature branches onto `fork` to pick upstream up. - The fork runs upstream's Orchestrator V2 (`orchestration-v2/`). Build fork features on it; the V1 orchestration is gone. From f05c2ae716befa11e3ea762d37c449a08d64d45c Mon Sep 17 00:00:00 2001 From: Paul Dittrich Date: Fri, 16 Oct 2026 14:43:09 +0200 Subject: [PATCH 3/4] feat(fork): upstream sync shows install failures, every red package and the lost fork lines Lessons from landing #158 by hand: a hand-merged patches/ file broke the install and hid everything after it, vpr typecheck stopped at the first red package, and a lost fork feature drowned in the audit. The prompt now also asks for repo-wide fixes and regenerated generated files. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-upstream-sync.sh | 37 ++++++++++++++++++++---- .github/workflows/fork-upstream-sync.yml | 27 +++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/.github/scripts/fork-upstream-sync.sh b/.github/scripts/fork-upstream-sync.sh index 1fd47c530b8ab..61e33552aa8a5 100755 --- a/.github/scripts/fork-upstream-sync.sh +++ b/.github/scripts/fork-upstream-sync.sh @@ -18,7 +18,8 @@ # checks install, typecheck, and the tests related to the files both # sides changed or the agent touched; report in checks.md # guards hard rules the result must meet; report in guards.md -# audit lines the fork added that the result lost; audit.md. Fails above +# audit lines the fork added that the result lost; audit.md, the lines +# themselves in audit-lines.txt. Fails above # AUDIT_MAX_LOST (default 50): a careful merge loses a few to # upstream rewrites, taking upstream's side of a conflict hundreds # verify fold, then checks, guards and audit in one go @@ -109,9 +110,31 @@ cmd_checks() { echo "::endgroup::" } step "vp install" vp install --no-frozen-lockfile + # Nothing after a failed install means anything (it once fell back to fork's + # lockfile after a hand-merged patches/ file), so it is the one thing to fix. + if [ "$failed" = 1 ]; then + { + echo + echo "**vp install failed; fix that first (see the ERR_PNPM lines in checks.log). Nothing else was checked.**" + } >> "$report" + return 1 + fi step "pnpm-lock.yaml committed" git diff --quiet HEAD -- pnpm-lock.yaml step "electron runtime" vp run --filter @t3tools/desktop ensure:electron - step "typecheck" vpr typecheck + # Every package, not `vpr typecheck`, which stops at the first red one. + local dir red="" + for dir in $(git ls-files -- 'apps/*/package.json' 'packages/*/package.json' | xargs dirname); do + node -e "process.exit(require('./$dir/package.json').scripts?.typecheck ? 0 : 1)" 2>/dev/null || continue + echo "::group::typecheck $dir"; echo "== typecheck $dir" >> "$log" + (cd "$dir" && vp run typecheck) 2>&1 | tee -a "$log" || red+=" $dir" + echo "::endgroup::" + done + if [ -n "$red" ]; then + echo "- ❌ typecheck:$red" >> "$report" + failed=1 + else + echo "- ✅ typecheck" >> "$report" + fi # vitest runs per package with the package's own `test` script flags. local files pkg rel extra @@ -174,15 +197,19 @@ cmd_guards() { } cmd_audit() { - local report="$SYNC_DIR/audit.md" f lost total=0 rows="" + local report="$SYNC_DIR/audit.md" f lines lost total=0 rows="" + # The lines themselves, for the agent's next round. + : > "$SYNC_DIR/audit-lines.txt" for f in $(changed --diff-filter=AM "$BASE" "$FORK_SHA" -- . ':!pnpm-lock.yaml'); do [ -z "$(git ls-tree HEAD -- "$f")" ] && continue - lost=$(git diff -U0 "$BASE" "$FORK_SHA" -- "$f" | grep -a '^+[^+]' | cut -c2- | + lines=$(git diff -U0 "$BASE" "$FORK_SHA" -- "$f" | grep -a '^+[^+]' | cut -c2- | awk 'length($0) - gsub(/[[:space:]]/, "&") >= 4' | sort -u | - grep -a -vxFf <(git show "HEAD:$f") | wc -l | tr -d ' ') + grep -a -vxFf <(git show "HEAD:$f")) + lost=$(printf '%s' "$lines" | grep -c '' || true) if [ "$lost" -gt 0 ]; then total=$((total + lost)) rows+="| \`$f\` | $lost |"$'\n' + { echo "== $f"; printf '%s\n' "$lines" | head -15; } >> "$SYNC_DIR/audit-lines.txt" fi done { diff --git a/.github/workflows/fork-upstream-sync.yml b/.github/workflows/fork-upstream-sync.yml index 369cd9182f974..50eef075a7b8b 100644 --- a/.github/workflows/fork-upstream-sync.yml +++ b/.github/workflows/fork-upstream-sync.yml @@ -226,9 +226,27 @@ jobs: \`cd && vp test run \`), and run verify at most once, at the end, with its output redirected to a file. The workflow runs verify after your session anyway and, while it is red, starts another session - with its reports (this is round $1 of $ROUNDS). audit.md lists lines the - fork added that the result lost; restore every one that belongs to a - fork feature, adapted to upstream's new code where upstream rewrote it. + with its reports (this is round $1 of $ROUNDS). + + Fix every red check across the whole repository, not only the files that + conflicted: fork code that merged cleanly still breaks when upstream moves + an import, changes an Effect API, removes a component or renames something + the fork relies on (such as the release stage behind the "esveo code" + branding). If \`vp install\` fails, fix that first; nothing after it means + anything. Never hand-merge a file under patches/: regenerate it against the + pristine package (\`pnpm patch @\`, apply the fork's change, + \`pnpm patch-commit\`), or drop it if upstream's version of the package no + longer needs it. Generated files (such as routeTree.gen.ts, which must keep + the fork's routes like git-graph and popout, or the mobile + generated-uniwind-themes.css) are regenerated with their generator, never + resolved by picking a side. + + audit.md lists lines the fork added that the result lost, and + audit-lines.txt the lines themselves. Fork features have been lost this + way before, silently. Account for every file in it: restore the fork + feature, adapted to upstream's new code where upstream rewrote it, or say + in summary.md why the lines are gone (for example upstream rewrote the + code and the feature still works another way). When done or stuck, and early enough to still have budget, write $SYNC_DIR/summary.md: the conflicts and how you resolved each, the fixes @@ -260,6 +278,9 @@ jobs: echo head -c 3000 "$SYNC_DIR/audit.md" 2>/dev/null || true echo + echo "The lost fork lines (account for each file, see the rules below):" + head -c 6000 "$SYNC_DIR/audit-lines.txt" 2>/dev/null || true + echo echo "Failing lines from $SYNC_DIR/checks.log (read the log for context):" sed 's/\x1b\[[0-9;]*m//g' "$SYNC_DIR/checks.log" 2>/dev/null | grep -E '^ *FAIL |error TS[0-9]|ERR_PNPM_|^error: ' | sort -u | head -80 || true From 5f67021d094ef7edb442e73278cfacc975bd85e6 Mon Sep 17 00:00:00 2001 From: Paul Dittrich Date: Fri, 16 Oct 2026 14:43:33 +0200 Subject: [PATCH 4/4] docs(fork): changelog for the self-repairing upstream sync Co-Authored-By: Claude Opus 5.5 --- docs/fork/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/fork/changelog.md b/docs/fork/changelog.md index 13b78c2d4f364..0392913d826c0 100644 --- a/docs/fork/changelog.md +++ b/docs/fork/changelog.md @@ -2,6 +2,7 @@ Every feature and fix this fork adds on top of [T3 Code](https://github.com/pingdotgg/t3code), each with the commits that made it. +- The upstream sync runs weekly and repairs itself: Copilot works in rounds fed with the red checks, the lost fork lines and install failures, and a sync that stays red continues in the next run instead of starting over ([b4c665e](https://github.com/esveo/t3code/commit/b4c665e902aae7ca42ca0ac8a1e72e3d04bcba3e), [ace0ecf](https://github.com/esveo/t3code/commit/ace0ecf85736e92496cd652c5564ccb39f59699b), [f05c2ae](https://github.com/esveo/t3code/commit/f05c2ae716befa11e3ea762d37c449a08d64d45c)). - Automations can run in an existing thread and, optionally, start each run as a quiet subthread that reports back when done ([eba20b3](https://github.com/esveo/t3code/commit/eba20b3b15ee8f2efd6f1e2d2bc521ac36973240), [3b723d0](https://github.com/esveo/t3code/commit/3b723d0b478bf53a3f4f1ef1c2d454412e09cfc3)). - Fix: switching the service to a new server also repoints the `t3` command, so `t3 pair` no longer falls back to an old install that mints links without today's permissions ([a845247](https://github.com/esveo/t3code/commit/a845247c8c62423064a3ca7ff5c977242b5e2d4c)). - The Usage page shows where Claude agents spent their time, per day or per hour, split into tasks such as thinking, tests, Git & CI or database, and filterable by project ([b141c61](https://github.com/esveo/t3code/commit/b141c610007d57712dc7a407452ab545beaf2949), [504eb8b](https://github.com/esveo/t3code/commit/504eb8b3100efe6e330d47202ffd501d08269b04), [221b2fd](https://github.com/esveo/t3code/commit/221b2fd893d32ff2fce3bf47651d37c8f0952219), [96e6522](https://github.com/esveo/t3code/commit/96e6522b7331766a51d2b81aaf649f1c07ced24f)).