From 41c7467794c7053b6e544f1797e580ec3e334b93 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 15:56:04 -0500 Subject: [PATCH 1/4] codex: resume frozen releases and overlap staging CI A failed local release leaves a complete immutable candidate, but a rebuild starts over against current master. That repeats the expensive rewrite and can turn a recoverable staging or promotion failure into a new conflict. Stable and unstable staging also run serially, and the publisher repeats full candidate verification after CI. Teach Meta/rebuild --resume to reload only a protected codex-refresh session. Hash the frozen files before staging, then recheck those hashes and live inputs before promotion instead of rebuilding or replaying the whole candidate. Reuse an exact existing staging ref and start both lane pushes before waiting, so their CI runs overlap. Record phase timings in the local session and print the same phases for remote publication. Keep the consumed one-shot recovery test self-contained by embedding its reviewed manifest; current meta correctly no longer carries that file. Test the real local path by rolling the fixture back to its pre-promotion state and publishing the same retained session without another preparation clone. Also check exact staging reuse and that both lane pushes precede either wait. --- .github/CODEX.md | 14 +- .github/workflows/codex-branch.sh | 229 ++++++++++++++++++++++++------ t/t9905-codex-branch.sh | 131 ++++++++++++++++- 3 files changed, 330 insertions(+), 44 deletions(-) diff --git a/.github/CODEX.md b/.github/CODEX.md index 55b4d7f84f3388..82db9f26e42a7a 100644 --- a/.github/CODEX.md +++ b/.github/CODEX.md @@ -170,6 +170,8 @@ After a plan change lands, publish the new generation: Meta/rebuild # or run preparation locally: Meta/rebuild --local +# or resume the printed frozen local session: +Meta/rebuild --resume ``` Both forms prepare an immutable snapshot, build and verify each candidate, @@ -178,12 +180,22 @@ stage exact SHAs, wait for fresh staging CI, and atomically promote `Meta/codex refresh --require-automation` is a local preview only; it pushes nothing. +Local preparation keeps the candidate bundle, input snapshot, and update +manifest in the printed session. A resumed release revalidates those exact +files and live inputs instead of rebuilding them. Existing staging refs reuse +their exact in-progress or successful CI run, and stable and unstable staging +start before either wait begins so their CI can run concurrently. An actual CI +failure still needs a successful rerun of that same run or a new candidate; +resume never treats failed CI as valid. Each attempt appends phase and total +durations to `codex-timings` in the session directory. + If a replay conflicts, the controller leaves published refs unchanged and prints the pinned recovery command. Resolve only in that disposable worktree, then run the printed `continue` and `publish-topics` commands. For pinned plans, `publish-topics` keeps source refs immutable and freezes the verified candidate, inputs, updates, and bundle in a local recovery session; stage -that exact session, wait for fresh staging CI, and promote it atomically. For +that exact session with `Meta/rebuild --resume`, wait for fresh staging CI, +and promote it atomically. For a pinned merge-shaped source, the controller uses its reviewed `source-base` as the exact old root and preserves the DAG across a moved generated base only when the two changed-path sets are disjoint. A linear dependent topic diff --git a/.github/workflows/codex-branch.sh b/.github/workflows/codex-branch.sh index ea7a8e60f00a7d..4012b16fb48e6e 100755 --- a/.github/workflows/codex-branch.sh +++ b/.github/workflows/codex-branch.sh @@ -6,6 +6,7 @@ me=codex-branch tmp_dir= temporary_worktree= preserve_worktree= +timings_file= script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) script_path=${CODEX_ENTRYPOINT:-$script_dir/$(basename "$0")} meta_config_path=codex.config @@ -25,10 +26,31 @@ die () { exit 1 } +now_seconds () { + date +%s +} + +record_timing () ( + phase=$1 + started=$2 + finished=$(now_seconds) || return 0 + elapsed=$((finished - started)) + say "Timing: $phase ${elapsed}s" + test -n "$timings_file" || return 0 + if ! printf '%s\t%s\t%s\t%s\n' "$phase" "$started" "$finished" \ + "$elapsed" >>"$timings_file" + then + say "warning: could not record release timing in '$timings_file'" + exit 0 + fi + chmod 600 "$timings_file" || + say "warning: could not protect release timing '$timings_file'" +) + usage () { cat <<-\EOF usage: codex-branch check-topic - or: codex-branch rebuild [--local] + or: codex-branch rebuild [--local | --resume ] [--enable-unstable | --disable-unstable] or: codex-branch publish or: codex-branch initialize [--remote ] [--base ] @@ -9208,26 +9230,37 @@ wait_for_refresh_run () ( rebuild_codex () { local_preparation= + resume_session= rebuild_unstable_mode= - for option in "$@" + while test $# -gt 0 do - case "$option" in + case "$1" in --local) test -z "$local_preparation" || { usage >&2; exit 129; } - local_preparation=t + local_preparation=prepare + shift + ;; + --resume) + test -z "$local_preparation" || { usage >&2; exit 129; } + require_arg "$@" + local_preparation=resume + resume_session=$2 + shift 2 ;; --enable-unstable) test -z "$rebuild_unstable_mode" || { usage >&2; exit 129; } rebuild_unstable_mode=enable + shift ;; --disable-unstable) test -z "$rebuild_unstable_mode" || { usage >&2; exit 129; } rebuild_unstable_mode=disable + shift ;; *) usage >&2; exit 129 ;; esac done - test -z "$rebuild_unstable_mode" || test -n "$local_preparation" || + test -z "$rebuild_unstable_mode" || test "$local_preparation" = prepare || die "changing the codex-unstable lane requires Meta/rebuild --local" require_operator_context if test -z "$local_preparation" @@ -9237,13 +9270,22 @@ rebuild_codex () { command -v zipinfo >/dev/null 2>&1 || die "Meta/rebuild requires zipinfo" fi + set -- + case "$local_preparation" in + prepare) set -- "$@" --local ;; + resume) set -- "$@" --resume "$resume_session" ;; + esac + case "$rebuild_unstable_mode" in + enable) set -- "$@" --enable-unstable ;; + disable) set -- "$@" --disable-unstable ;; + esac refresh_meta_controller "$@" - if test -n "$local_preparation" - then - rebuild_codex_locally - return - fi + case "$local_preparation" in + prepare) rebuild_codex_locally; return ;; + resume) rebuild_codex_locally "$resume_session"; return ;; + esac + remote_total_started=$(now_seconds) make_tmp_dir repository=openai/git endpoint="repos/$repository/actions/workflows/codex.yml/dispatches" @@ -9269,6 +9311,7 @@ rebuild_codex () { CODEX_EXPECTED_RUN_ATTEMPT=1 export CODEX_EXPECTED_RUN_ATTEMPT publish_run "$run_id" + record_timing end-to-end "$remote_total_started" } artifact_value () ( @@ -9339,7 +9382,7 @@ wait_for_staging_ci () ( do attempt=$((attempt + 1)) run_id=$("$gh_command" api --hostname github.com "$workflow_runs" --jq \ - ".workflow_runs | map(select(.id > ($baseline | tonumber) and .head_branch == \"$staging\" and .head_sha == \"$candidate\" and .event == \"push\" and .path == \".github/workflows/main.yml\")) | sort_by(.id) | .[0].id // empty") || + ".workflow_runs | map(select(.id > ($baseline | tonumber) and .head_branch == \"$staging\" and .head_sha == \"$candidate\" and .event == \"push\" and .path == \".github/workflows/main.yml\")) | sort_by(.id) | reverse | .[0].id // empty") || die "could not query staging CI" test -z "$run_id" || break sleep 5 @@ -9457,6 +9500,32 @@ freeze_local_candidate () { done } +write_local_candidate_identity () ( + metadata=$1 + output=$2 + : >"$output" || die "could not create local candidate identity" + for name in codex.bundle codex-candidate codex-inputs codex-updates + do + test -f "$metadata/$name" && test ! -L "$metadata/$name" || + die "local candidate file '$name' is not regular" + oid=$(git hash-object "$metadata/$name") || + die "could not identify local candidate file '$name'" + printf '%s\t%s\n' "$name" "$oid" >>"$output" || + die "could not record local candidate file '$name'" + done +) + +revalidate_local_candidate () ( + metadata=$1 + expected_identity=$2 + actual_identity=$tmp_dir/local-candidate-identity.current + write_local_candidate_identity "$metadata" "$actual_identity" + cmp -s "$expected_identity" "$actual_identity" || + die "frozen local candidate changed while staging CI ran" + verify_inputs --remote origin --base master --codex codex \ + "$metadata/codex-inputs" +) + verify_candidate_bundle () { bundle=$1 candidate=$2 @@ -9580,6 +9649,25 @@ prepare_local_candidate () { freeze_local_candidate "$session" "$local_candidate_dir" } +resume_local_candidate () { + requested_session=$1 + test -d "$requested_session" && test ! -L "$requested_session" || + die "resume session '$requested_session' is not a directory" + session=$(CDPATH= cd "$requested_session" && pwd -P) || + die "could not resolve resume session '$requested_session'" + common_dir=$(git rev-parse --path-format=absolute --git-common-dir) || + die "could not locate the shared repository state" + session_parent=$common_dir/codex-refresh + case "$session/" in + "$session_parent"/*) ;; + *) die "resume session must be inside '$session_parent'" ;; + esac + make_tmp_dir + local_candidate_dir=$tmp_dir/local-candidate + freeze_local_candidate "$session" "$local_candidate_dir" + say "Resuming local preparation session: $session" +} + reconcile_candidate_pr_state () ( inputs=$1 updates=$2 @@ -9593,48 +9681,76 @@ reconcile_candidate_pr_state () ( fi ) -stage_and_wait_for_ci () { +prepare_staging_ci () ( repository=$1 candidate=$2 inputs=$3 updates=$4 - staging=codex-staging + staging=$5 + started=$(now_seconds) workflow_runs="repos/$repository/actions/workflows/main.yml/runs?branch=$staging&event=push&head_sha=$candidate&per_page=100" - baseline=$(gh api --hostname github.com "$workflow_runs" --jq \ - '[.workflow_runs[].id] | max // 0') || - die "could not record the staging CI baseline" - case "$baseline" in - ''|*[!0-9]*) die "staging CI baseline is not a numeric run ID" ;; - esac + live=$(remote_head_oid origin "refs/heads/$staging") || + die "could not inspect $staging" + if test "$live" = "$candidate" + then + baseline=0 + say "Reusing $staging at exact candidate $candidate." + else + baseline=$(gh api --hostname github.com "$workflow_runs" --jq \ + '[.workflow_runs[].id] | max // 0') || + die "could not record the $staging CI baseline" + case "$baseline" in + ''|*[!0-9]*) die "$staging CI baseline is not a numeric run ID" ;; + esac + # Record progress before stage_candidate calls the shell's other + # stateful helpers, which intentionally reuse global variable names. + printf '%s\t%s\t%s\t%s\n' "$staging" "$candidate" "$baseline" \ + "$started" >"$6" || die "could not record $staging progress" + say "Publishing exact candidate $candidate to $staging." + stage_candidate --remote origin --staging "$staging" \ + --inputs "$inputs" --updates "$updates" --require-automation + exit 0 + fi + printf '%s\t%s\t%s\t%s\n' "$staging" "$candidate" "$baseline" \ + "$started" >"$6" || die "could not record $staging progress" +) + +wait_for_prepared_staging_ci () ( + repository=$1 + progress=$2 + IFS="$tab" read -r staging candidate baseline started <"$progress" || + die "could not read staging progress" + wait_for_staging_ci gh "$repository" "$candidate" "$baseline" \ + "$staging" + record_timing "$staging-ci" "$started" +) + +stage_and_wait_for_ci () { + repository=$1 + candidate=$2 + inputs=$3 + updates=$4 publisher=$(gh api --hostname github.com user --jq .login) || die "could not identify the GitHub CLI user" test -n "$publisher" || die "GitHub CLI returned no authenticated user" - say "Publishing the prepared candidate with the credentials for origin." say "GitHub API user: $publisher" - stage_candidate --remote origin --staging "$staging" \ - --inputs "$inputs" --updates "$updates" --require-automation - reconcile_candidate_pr_state "$inputs" "$updates" - wait_for_staging_ci gh "$repository" "$candidate" "$baseline" \ - "$staging" + stable_progress=$tmp_dir/codex-staging-progress + prepare_staging_ci "$repository" "$candidate" "$inputs" "$updates" \ + codex-staging "$stable_progress" + unstable_progress= unstable_candidate=$(awk -F '\t' \ '$1 == "refs/heads/codex-unstable" { print $3 }' "$updates") if test -n "$unstable_candidate" && ! is_null_oid "$unstable_candidate" then - staging=codex-unstable-staging - workflow_runs="repos/$repository/actions/workflows/main.yml/runs?branch=$staging&event=push&head_sha=$unstable_candidate&per_page=100" - baseline=$(gh api --hostname github.com "$workflow_runs" --jq \ - '[.workflow_runs[].id] | max // 0') || - die "could not record the unstable staging CI baseline" - case "$baseline" in - ''|*[!0-9]*) die "unstable staging CI baseline is not a numeric run ID" ;; - esac - stage_candidate --remote origin --staging "$staging" \ - --inputs "$inputs" --updates "$updates" --require-automation - reconcile_candidate_pr_state "$inputs" "$updates" - wait_for_staging_ci gh "$repository" "$unstable_candidate" \ - "$baseline" "$staging" + unstable_progress=$tmp_dir/codex-unstable-staging-progress + prepare_staging_ci "$repository" "$unstable_candidate" "$inputs" \ + "$updates" codex-unstable-staging "$unstable_progress" fi + reconcile_candidate_pr_state "$inputs" "$updates" + wait_for_prepared_staging_ci "$repository" "$stable_progress" + test -z "$unstable_progress" || + wait_for_prepared_staging_ci "$repository" "$unstable_progress" } close_published_topic_review () ( @@ -9733,18 +9849,42 @@ close_published_topic_reviews () ( ) rebuild_codex_locally () { - prepare_local_candidate + total_started=$(now_seconds) + phase_started=$(now_seconds) + if test $# = 1 + then + resume_local_candidate "$1" + timings_file=$session/codex-timings + record_timing resume-load "$phase_started" + else + test $# = 0 || { usage >&2; exit 129; } + prepare_local_candidate + timings_file=$session/codex-timings + record_timing preparation "$phase_started" + fi + phase_started=$(now_seconds) verify_local_candidate "$local_candidate_dir" + local_candidate_identity=$tmp_dir/local-candidate-identity + write_local_candidate_identity "$local_candidate_dir" \ + "$local_candidate_identity" + record_timing candidate-verification "$phase_started" candidate=$(sed -n '1p' "$local_candidate_dir/codex-candidate") + phase_started=$(now_seconds) stage_and_wait_for_ci openai/git "$candidate" \ "$local_candidate_dir/codex-inputs" \ "$local_candidate_dir/codex-updates" + record_timing staging-total "$phase_started" require_operator_context - verify_local_candidate "$local_candidate_dir" + phase_started=$(now_seconds) + revalidate_local_candidate "$local_candidate_dir" \ + "$local_candidate_identity" + record_timing input-revalidation "$phase_started" + phase_started=$(now_seconds) promote --remote origin --staging codex-staging \ --inputs "$local_candidate_dir/codex-inputs" \ --updates "$local_candidate_dir/codex-updates" \ --require-automation + record_timing promotion "$phase_started" reconcile_candidate_pr_state "$local_candidate_dir/codex-inputs" \ "$local_candidate_dir/codex-updates" say "Published codex candidate $candidate from local preparation session $session." @@ -9754,6 +9894,7 @@ rebuild_codex_locally () { say "warning: publication succeeded, but its reviewed topic pull request could not be closed." fi say "Generated commits identify $bot_name <$bot_email>; the push uses your configured origin credentials." + record_timing total "$total_started" } publish_run () { @@ -9762,6 +9903,8 @@ publish_run () { case "$run_id" in ''|*[!0-9]*) die "Meta/publish requires a numeric Actions run ID" ;; esac + total_started=$(now_seconds) + phase_started=$(now_seconds) require_operator_context command -v unzip >/dev/null 2>&1 || die "Meta/publish requires unzip" @@ -9863,14 +10006,19 @@ publish_run () { cmp -s "$tmp_dir/run" "$tmp_dir/run-current" || die "Actions run $run_id changed after artifact validation; start a fresh Meta/rebuild" + record_timing candidate-verification "$phase_started" + phase_started=$(now_seconds) stage_and_wait_for_ci "$repository" "$artifact_candidate" \ "$metadata/codex-inputs" "$metadata/codex-updates" + record_timing staging-total "$phase_started" read_refresh_run gh "$repository" "$run_id" "$tmp_dir/run-after-ci" cmp -s "$tmp_dir/run" "$tmp_dir/run-after-ci" || die "Actions run $run_id changed while staging CI ran; start a fresh Meta/rebuild" + phase_started=$(now_seconds) promote --remote origin --staging codex-staging \ --inputs "$metadata/codex-inputs" \ --updates "$metadata/codex-updates" --require-automation + record_timing promotion "$phase_started" reconcile_candidate_pr_state "$metadata/codex-inputs" \ "$metadata/codex-updates" say "Published codex candidate $artifact_candidate from Actions run $run_id." @@ -9880,6 +10028,7 @@ publish_run () { say "warning: publication succeeded, but its reviewed topic pull request could not be closed." fi say "Generated commits identify $bot_name <$bot_email>; the push uses your configured origin credentials." + record_timing total "$total_started" } resolve_rebase () { diff --git a/t/t9905-codex-branch.sh b/t/t9905-codex-branch.sh index b15a5ed1d5f6c1..e0cfe4a0573540 100755 --- a/t/t9905-codex-branch.sh +++ b/t/t9905-codex-branch.sh @@ -6346,6 +6346,35 @@ test_expect_success PYTHON 'publish-run authenticates the artifact and promotes test_must_fail git show-ref --verify \ refs/codex-output/candidate && test_must_fail git show-ref --verify refs/codex-output/meta && + test_must_fail git --git-dir=../publish-run.git show-ref --verify \ + refs/heads/codex-staging && + + # Recreate the state immediately before promotion, then resume the + # retained immutable session without rebuilding it on master. + while IFS="$(printf "\t")" read -r ref old new + do + git --git-dir=../publish-run.git update-ref \ + "$ref" "$old" "$new" || return 1 + done <"$local_session/codex-updates" && + resume_controller=$(awk -F "$(printf "\t")" \ + '\''$1 == "refs/heads/meta" { print $2 }'\'' \ + "$local_session/codex-updates") && + git -C Meta -c advice.detachedHead=false switch --detach \ + "$resume_controller" && + : >"$support/git.log" && + : >"$support/gh.log" && + FAKE_DYNAMIC_CANDIDATE=1 \ + run_prepared "$support/good.zip" rebuild \ + --resume "$local_session" \ + >"$support/resume.out" 2>"$support/resume.err" && + test_grep "Resuming local preparation session: $local_session" \ + "$support/resume.out" && + test_grep "Published codex candidate $local_candidate from local preparation session $local_session" \ + "$support/resume.out" && + ! grep -F "clone --shared --no-checkout" "$support/git.log" && + test_grep "^resume-load" "$local_session/codex-timings" && + test "$local_candidate" = "$(git --git-dir=../publish-run.git \ + rev-parse refs/heads/codex)" && test_must_fail git --git-dir=../publish-run.git show-ref --verify \ refs/heads/codex-staging ) @@ -10138,8 +10167,23 @@ test_expect_success 'publication closes only its exact integrated topic review' ) ' -test_expect_success 'checked-in release recovery manifest is the bound incident' ' - test "$(git hash-object "$codex_root/codex.release-recovery")" = \ +test_expect_success 'consumed release recovery remains bound to its reviewed incident' ' + { + printf "%s\n" "[recovery]" && + printf "\t%s\n" \ + "version = 1" \ + "baseline-meta = ca1f7b4c4cc36f2c2fc1fed8d34e6b83d0d921c0" \ + "lane = codex" \ + "topic = refs/heads/tb/codex/release" \ + "old-source-tip = ba107e0ae8c7142238bb612e530d51d42f0280d3" \ + "new-source-tip = 40589b5333835ecd5e1b6187cbcec45d8382303e" \ + "source-base = a97fcc37c2bc6340a8d7ce78dedf227aac4e9aa7" \ + "merge = refs/heads/master" \ + "pull-request = 22" \ + "pull-request-head-ref = refs/heads/ttaylorr/release-source-ref-wip" \ + "pull-request-head-tip = 257daf6f3d083ada30e26547fc50451ddcfbfb1d" + } >reviewed-release-recovery && + test "$(git hash-object reviewed-release-recovery)" = \ 8ef04578ac791a3499b1406a1d0ae9884bacd559 && git init --bare release-recovery-binding.git && test_create_repo release-recovery-binding-source && @@ -10151,7 +10195,8 @@ test_expect_success 'checked-in release recovery manifest is the bound incident' git commit -m "release recovery binding base" && git branch codex && git switch -c meta && - cp "$codex_root/codex.release-recovery" . && + cp "$TRASH_DIRECTORY/reviewed-release-recovery" \ + codex.release-recovery && git add codex.release-recovery && git commit -m "arm shipped recovery manifest" && git push origin master codex meta @@ -10560,4 +10605,84 @@ test_expect_success 'label reconciliation preserves unrelated labels and rejects ! grep -F keep-me pr-state-mutations/pr-state-data/mutations ' +test_expect_success 'both release lanes start CI before either lane waits' ' + test_create_repo concurrent-staging && + ( + cd concurrent-staging && + test_commit stable && + stable=$(git rev-parse HEAD) && + test_commit unstable && + unstable=$(git rev-parse HEAD) && + printf "refs/heads/codex\t-\t%s\nrefs/heads/codex-unstable\t-\t%s\n" \ + "$stable" "$unstable" >updates && + : >inputs && + mkdir bin scratch && + cat >bin/gh <<-\EOF && + #!/bin/sh + case "$*" in + *" user --jq .login") printf "%s\n" test-publisher ;; + *"actions/workflows/main.yml/runs"*) printf "%s\n" 100 ;; + *) exit 91 ;; + esac + EOF + chmod +x bin/gh && + cat >run-concurrent <<-\EOF && + #!/bin/sh + set -- --help + . "$CODEX_BRANCH" >/dev/null + cleanup () { :; } + tmp_dir=$PWD/scratch + remote_head_oid () { + case "${FAKE_REUSE:-}:$2" in + 1:refs/heads/codex-staging) printf "%s\n" "$FAKE_STABLE" ;; + 1:refs/heads/codex-unstable-staging) + printf "%s\n" "$FAKE_UNSTABLE" + ;; + esac + } + stage_candidate () { + lane= + while test $# -gt 0 + do + case "$1" in + --staging) lane=$2; shift 2 ;; + *) shift ;; + esac + done + printf "stage %s\n" "$lane" >>"$FAKE_EVENT_LOG" + } + reconcile_candidate_pr_state () { + printf "%s\n" reconcile >>"$FAKE_EVENT_LOG" + } + wait_for_staging_ci () { + printf "wait %s baseline=%s\n" "$5" "$4" \ + >>"$FAKE_EVENT_LOG" + } + stage_and_wait_for_ci openai/git "$FAKE_STABLE" \ + "$FAKE_INPUTS" "$FAKE_UPDATES" + EOF + chmod +x run-concurrent && + PATH="$PWD/bin:$PATH" CODEX_BRANCH="$codex_branch" \ + FAKE_EVENT_LOG="$PWD/events" FAKE_STABLE="$stable" \ + FAKE_UNSTABLE="$unstable" FAKE_INPUTS="$PWD/inputs" \ + FAKE_UPDATES="$PWD/updates" sh run-concurrent >run.out && + printf "stage codex-staging\nstage codex-unstable-staging\nreconcile\nwait codex-staging baseline=100\nwait codex-unstable-staging baseline=100\n" \ + >expect && + test_cmp expect events && + : >events && + PATH="$PWD/bin:$PATH" CODEX_BRANCH="$codex_branch" \ + FAKE_REUSE=1 FAKE_EVENT_LOG="$PWD/events" \ + FAKE_STABLE="$stable" FAKE_UNSTABLE="$unstable" \ + FAKE_INPUTS="$PWD/inputs" FAKE_UPDATES="$PWD/updates" \ + sh run-concurrent >reuse.out && + printf "reconcile\nwait codex-staging baseline=0\nwait codex-unstable-staging baseline=0\n" \ + >reuse.expect && + test_cmp reuse.expect events && + test_grep "Reusing codex-staging at exact candidate $stable" \ + reuse.out && + test_grep "Reusing codex-unstable-staging at exact candidate $unstable" \ + reuse.out + ) +' + test_done From b07c73d4e531df32bec5bf5187fd275c9f6dbb8d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 17:30:04 -0500 Subject: [PATCH 2/4] codex: preserve overlapping pinned merge recovery When the reviewed unstable merge graph overlaps changes made while rebuilding stable on current master, the controller currently stops before leaving a recoverable state. Restacking that graph by hand is slow and advances reviewed source refs only because the generated base moved. Run the topology-preserving rebase in an isolated shared clone and retain its state. Train rerere from the published graph, let resolve/continue resume either the stable or unstable graph, and import only the completed objects. Source refs remain immutable. Verify a one-to-one commit mapping and unchanged parent topology before freezing the ordinary pinned recovery session. Exercise the dual-lane overlap, conflict resolution, and unchanged remote refs end to end. --- .github/CODEX.md | 20 +- .github/workflows/codex-branch.sh | 373 +++++++++++++++++++++++------- t/t9905-codex-branch.sh | 74 +++--- 3 files changed, 345 insertions(+), 122 deletions(-) diff --git a/.github/CODEX.md b/.github/CODEX.md index 82db9f26e42a7a..5382843ae96199 100644 --- a/.github/CODEX.md +++ b/.github/CODEX.md @@ -195,15 +195,17 @@ then run the printed `continue` and `publish-topics` commands. For pinned plans, `publish-topics` keeps source refs immutable and freezes the verified candidate, inputs, updates, and bundle in a local recovery session; stage that exact session with `Meta/rebuild --resume`, wait for fresh staging CI, -and promote it atomically. For -a pinned merge-shaped source, the controller uses its reviewed `source-base` -as the exact old root and preserves the DAG across a moved generated base -only when the two changed-path sets are disjoint. A linear dependent topic -can extend that graph when its reviewed boundary is the exact pinned source -tip of a prerequisite already rooted in the graph. An overlapping base move, -an unrelated boundary, or another merge-shaped source with a different -reviewed root fails closed; restack the approved topic and pin its new head -instead of flattening or guessing. +and promote it atomically. For a pinned merge-shaped source, the controller +uses its reviewed `source-base` as the exact old root and preserves the DAG +across a moved generated base. Disjoint changes replay directly. Overlap uses +one isolated topology-aware rebase, trained from the published graph's rerere +history; `resolve` preserves a stopped stable or unstable graph and `continue` +resumes it without moving source refs. Before freezing the candidate, the +controller verifies a one-to-one commit mapping and unchanged parent topology. +A linear dependent topic can extend that graph when its reviewed boundary is +the exact pinned source tip of a prerequisite already rooted in the graph. An +unrelated boundary or another merge-shaped source with a different reviewed +root still fails closed instead of flattening or guessing. ## Required automation topic diff --git a/.github/workflows/codex-branch.sh b/.github/workflows/codex-branch.sh index 4012b16fb48e6e..6a07f73c67ca4c 100755 --- a/.github/workflows/codex-branch.sh +++ b/.github/workflows/codex-branch.sh @@ -1543,6 +1543,22 @@ state_path () { --git-path codex-rewrite-state } +recovery_state_path () { + worktree=$1 + state=$(state_path "$worktree") || return 1 + if test -f "$state/state-root" + then + state=$(sed -n '1p' "$state/state-root") + case "$state" in + /*) ;; + *) die "merge-graph recovery state is not absolute" ;; + esac + test -d "$state" || + die "merge-graph recovery state '$state' is missing" + fi + printf '%s\n' "$state" +} + state_value () { state=$1 key=$2 @@ -4309,6 +4325,40 @@ process_disjoint_pinned_merge_graph () ( die "could not finish pinned merge updates" ) +finish_merge_graph_rebase () ( + state=$1 + private=$(state_value "$state" merge-rebase-worktree) + root_worktree=$(state_value "$state" recovery-root-worktree) + base_oid=$(state_value "$state" base-oid) + tracking=$state/private-topic-refs + + set -- git -C "$root_worktree" fetch --no-tags "$private" + while IFS="$tab" read -r name old ref + do + set -- "$@" "$ref" + done <"$tracking" + "$@" >/dev/null 2>&1 || + die "could not import the completed merge-graph rebase" + + while IFS="$tab" read -r name old ref + do + if test "$old" = "$(state_value "$state" merge-graph-root)" + then + new=$base_oid + else + new=$(git -C "$private" rev-parse "$ref") || + die "could not read rewritten topic '$name'" + fi + git -C "$root_worktree" cat-file -e "$new^{commit}" || + die "topic '$name' was not imported into the main object database" + git -C "$root_worktree" merge-base --is-ancestor \ + "$base_oid" "$new" || + die "rewritten topic '$name' lost its production base" + result_record "$state/results" "$name" "$new" + done <"$tracking" + finish_updates "$state" +) + process_merge_graph () ( worktree=$1 state=$2 @@ -4354,10 +4404,13 @@ process_merge_graph () ( if test -f "$state/pinned-merge-root" then prepare_pinned_merge_overlay "$state" "$root" "$base_oid" - test -f "$state/pinned-merge-disjoint-base" || - die "pinned merge graph overlaps its moved base; restack it explicitly before publication" - process_disjoint_pinned_merge_graph "$state" - return 0 + if test -f "$state/pinned-merge-disjoint-base" + then + process_disjoint_pinned_merge_graph "$state" + return 0 + fi + : >"$state/pinned-merge-overlap-rebase" || + die "could not retain pinned merge overlap state" fi if test "$root" = "$base_oid" @@ -4410,6 +4463,16 @@ process_merge_graph () ( git -c core.fsmonitor=false clone --shared --no-checkout --no-tags \ "$source" "$private" >/dev/null 2>&1 || die "could not isolate the merge replay" + private_state=$(state_path "$private") || + die "could not locate the private merge-replay state" + mkdir -p "$private_state" || + die "could not prepare the private merge-replay state" + printf '%s\n' "$state" >"$private_state/state-root" || + die "could not link the private merge-replay state" + printf '%s\n' "$private" >"$state/merge-rebase-worktree" || + die "could not retain the merge-replay worktree" + printf '%s\n' "$worktree" >"$state/recovery-root-worktree" || + die "could not retain the recovery worktree" : >"$tracking" index=0 while IFS="$tab" read -r name oid @@ -4426,6 +4489,20 @@ process_merge_graph () ( -c advice.detachedHead=false switch --detach "$aggregate" \ >/dev/null 2>&1 || die "could not check out the synthetic merge graph" + if test -f "$state/pinned-plan-mode" && + test -f "$state/published-base-oid" && + test -f "$state/published-codex-oid" + then + published_base=$(state_value "$state" published-base-oid) + published_output=$(state_value "$state" published-codex-oid) + if test "$published_base" != "$published_output" && + git merge-base --is-ancestor "$published_base" \ + "$published_output" + then + train_rerere "$private" "$published_base" \ + "$published_output" + fi + fi if ! GIT_OBJECT_DIRECTORY=$object_directory \ GIT_COMMITTER_NAME=$bot_name GIT_COMMITTER_EMAIL=$bot_email \ GIT_SEQUENCE_EDITOR=true GIT_EDITOR=true git -C "$private" \ @@ -4439,6 +4516,11 @@ process_merge_graph () ( --keep-empty --reapply-cherry-picks --no-autostash \ --onto "$base_oid" "$root" then + if continue_rerere_resolution "$private" + then + finish_merge_graph_rebase "$state" + return 0 + fi owner=$(awk -F '\t' 'NR == 1 { print $1 }' "$state/plan") old=$(awk -F '\t' -v name="$owner" \ '$1 == name { print $2; exit }' "$topics") @@ -4446,22 +4528,7 @@ process_merge_graph () ( "$old" "$root" "$base_oid" || return 1 return 1 fi - while IFS="$tab" read -r name old ref - do - if test "$old" = "$root" - then - new=$base_oid - else - new=$(git -C "$private" rev-parse "$ref") || - die "could not read rewritten topic '$name'" - fi - git cat-file -e "$new^{commit}" || - die "topic '$name' was not imported into the main object database" - git merge-base --is-ancestor "$base_oid" "$new" || - die "rewritten topic '$name' lost its production base" - result_record "$state/results" "$name" "$new" - done <"$tracking" - finish_updates "$state" + finish_merge_graph_rebase "$state" ) verify_merge_topology () ( @@ -4596,29 +4663,33 @@ verify_merge_topology () ( if test -f "$state/pinned-merge-root" then prepare_pinned_merge_overlay "$state" "$root" "$base" - test -f "$state/pinned-merge-disjoint-base" || - die "pinned merge graph overlaps its moved base" - LC_ALL=C sort -u "$source" \ - >"$state/verified-tree-commits" || - die "could not sort verified merge commits" - write_pinned_merge_overlay_trees "$root" "$base" \ - "$state/verified-tree-commits" \ - "$state/verified-expected-trees" - exec 3<"$state/verified-expected-trees" - while IFS= read -r old - do - IFS= read -r expected_tree <&3 || - die "could not read the expected replayed tree for $old" - new=$(awk -F '\t' -v oid="$old" \ - '$1 == oid { print $2; exit }' "$map") - test -n "$new" || - die "merge rewrite has no tree mapping for $old" - actual_tree=$(git rev-parse "$new^{tree}") || - die "could not inspect replayed tree $new" - test "$actual_tree" = "$expected_tree" || - die "pinned merge replay changes its reviewed tree outside the moved base" - done <"$state/verified-tree-commits" - exec 3<&- + if test -f "$state/pinned-merge-disjoint-base" + then + LC_ALL=C sort -u "$source" \ + >"$state/verified-tree-commits" || + die "could not sort verified merge commits" + write_pinned_merge_overlay_trees "$root" "$base" \ + "$state/verified-tree-commits" \ + "$state/verified-expected-trees" + exec 3<"$state/verified-expected-trees" + while IFS= read -r old + do + IFS= read -r expected_tree <&3 || + die "could not read the expected replayed tree for $old" + new=$(awk -F '\t' -v oid="$old" \ + '$1 == oid { print $2; exit }' "$map") + test -n "$new" || + die "merge rewrite has no tree mapping for $old" + actual_tree=$(git rev-parse "$new^{tree}") || + die "could not inspect replayed tree $new" + test "$actual_tree" = "$expected_tree" || + die "pinned merge replay changes its reviewed tree outside the moved base" + done <"$state/verified-tree-commits" + exec 3<&- + else + test -f "$state/pinned-merge-overlap-rebase" || + die "overlapping pinned merge graph was not rebased by the controller" + fi fi LC_ALL=C sort "$source" >"$source.sorted" || @@ -4744,24 +4815,65 @@ write_unstable_failure () { write_merge_graph_failure () { path=$1 state=$2 - worktree=$3 + worktree=$(state_value "$state" merge-rebase-worktree) test -n "$path" || return 0 failed_owner=$(state_value "$state" failed-owner) failed_commit=$(state_value "$state" failed-commit) output_name=$(state_value "$state" codex-name) + root_worktree=$(state_value "$state" recovery-root-worktree) + root_state=$(state_path "$root_worktree") { say "## No refs were updated" say say "Rebasing merge-shaped topic \`$failed_owner\` stopped while applying \`$failed_commit\`." say "Neither \`$output_name\`, \`meta\`, nor a topic branch was updated." say - say "Resolve this merge-graph conflict manually: restack the affected" - say "topic and its descendants onto their current prerequisites," - say "publish the coherent topic graph in one exact-lease atomic push," - say "and run \`Meta/rebuild\` again." - say "The linear \`resolve\`/\`continue\` recovery commands do not" - say "reconstruct a merge-shaped topic graph." + if test -z "$preserve_worktree" + then + inputs_oid=$(input_oid "$root_state/inputs") + remote=$(state_value "$root_state" remote) + base_name=$(state_value "$root_state" base-name) + codex_name=$(state_value "$root_state" codex-name) + require_automation=$(state_value "$root_state" \ + require-automation) + say "Reproduce and preserve this exact topology-aware rebase with:" + say + set -- "$script_path" resolve --remote "$remote" \ + --base "$base_name" --codex "$codex_name" + test -z "$require_automation" || + set -- "$@" --require-automation + set -- "$@" --inputs-oid "$inputs_oid" + printf ' ' + for arg in "$@" + do + printf '%s ' "$(shell_quote "$arg")" + done + printf '\n' + say + say "The resolve command leaves the complete stopped graph in a" + say "disposable worktree and prints the exact continue command." + return + fi + say "The controller preserved the complete topology-aware rebase in:" + say + say " $(shell_quote "$worktree")" + say + say "Resolve the stopped commit there, then continue through the pinned" + say "controller. It will retain every rewritten topic boundary and verify" + say "that the result has exactly the reviewed commits and parent topology:" + say + say " cd $(shell_quote "$worktree")" + say " git status" + say " git rebase --show-current-patch" + say " # Edit the conflicted files." + say " git add " + say " git diff --cached --check" + say " $(shell_quote "$script_path") continue --worktree ." + say + say "Repeat the edit/add/continue sequence for later conflicts. No source" + say "ref moves; the completed stable and unstable candidate is frozen for" + say "the normal staging-CI and atomic-promotion gates." if test -n "$(git -C "$worktree" -c core.fsmonitor=false \ diff --name-only --diff-filter=U)" then @@ -5327,6 +5439,45 @@ create_unstable_sentinel () ( die "could not create the empty codex-unstable sentinel" ) +finish_unstable_candidate () ( + worktree=$1 + root_state=$2 + unstable_state=$3 + stable_candidate=$4 + unstable_old=$5 + failure_file=$6 + + if ! unstable_candidate=$(assemble_candidate "$worktree" \ + "$unstable_state") + then + if test -f "$unstable_state/integration-failed-name" + then + write_integration_failure "$failure_file" \ + "$unstable_state" "$worktree" + die "codex-unstable integration conflicts while merging '$(state_value "$unstable_state" integration-failed-name)'; no refs were updated" + fi + die "codex-unstable candidate validation failed; no refs were updated" + fi + git merge-base --is-ancestor "$stable_candidate" \ + "$unstable_candidate" || + die "codex-unstable candidate does not contain its exact codex base" + test "$stable_candidate" != "$unstable_candidate" || + die "codex-unstable candidate is not strictly ahead of codex" + verify_unstable_control_paths "$stable_candidate" \ + "$unstable_candidate" "$unstable_state" + if ! is_null_oid "$unstable_old" && + git merge-base --is-ancestor "$stable_candidate" "$unstable_old" && + test "$(git rev-parse "$unstable_candidate^{tree}")" = \ + "$(git rev-parse "$unstable_old^{tree}")" && + codex_has_expected_integrations "$unstable_state" "$unstable_old" + then + unstable_candidate=$unstable_old + fi + printf '%s\n' "$unstable_candidate" \ + >"$root_state/unstable-output-oid" || + die "could not retain the unstable output candidate" +) + prepare_unstable_candidate () ( worktree=$1 state=$2 @@ -5398,6 +5549,21 @@ prepare_unstable_candidate () ( fi unstable_state=$state/unstable + if test -f "$unstable_state/topic-updates" + then + test "$(state_value "$unstable_state" base-oid)" = \ + "$stable_candidate" || + die "completed unstable recovery belongs to a different stable candidate" + require_state_controller "$unstable_state" + finish_unstable_candidate "$worktree" "$root_state" \ + "$unstable_state" "$stable_candidate" "$unstable_old" \ + "$failure_file" + return + fi + if test -d "$unstable_state" + then + die "unstable recovery is incomplete; continue its preserved rebase instead of rebuilding it" + fi mkdir -p "$unstable_state" || die "could not prepare unstable reconstruction state" cp "$state/inputs" "$unstable_state/inputs" || @@ -5479,35 +5645,8 @@ prepare_unstable_candidate () ( fi die "conflict while rebasing unstable topic '$(state_value "$unstable_state" failed-owner)'; no refs were updated" fi - if ! unstable_candidate=$(assemble_candidate "$worktree" \ - "$unstable_state") - then - if test -f "$unstable_state/integration-failed-name" - then - write_integration_failure "$failure_file" \ - "$unstable_state" "$worktree" - die "codex-unstable integration conflicts while merging '$(state_value "$unstable_state" integration-failed-name)'; no refs were updated" - fi - die "codex-unstable candidate validation failed; no refs were updated" - fi - git merge-base --is-ancestor "$stable_candidate" \ - "$unstable_candidate" || - die "codex-unstable candidate does not contain its exact codex base" - test "$stable_candidate" != "$unstable_candidate" || - die "codex-unstable candidate is not strictly ahead of codex" - verify_unstable_control_paths "$stable_candidate" \ - "$unstable_candidate" "$unstable_state" - if ! is_null_oid "$unstable_old" && - git merge-base --is-ancestor "$stable_candidate" "$unstable_old" && - test "$(git rev-parse "$unstable_candidate^{tree}")" = \ - "$(git rev-parse "$unstable_old^{tree}")" && - codex_has_expected_integrations "$unstable_state" "$unstable_old" - then - unstable_candidate=$unstable_old - fi - printf '%s\n' "$unstable_candidate" \ - >"$root_state/unstable-output-oid" || - die "could not retain the unstable output candidate" + finish_unstable_candidate "$worktree" "$root_state" "$unstable_state" \ + "$stable_candidate" "$unstable_old" "$failure_file" ) initialize_config () { @@ -10082,12 +10221,28 @@ resolve_rebase () { state=$(state_path "$worktree") initialize_rewrite "$remote" "$base_name" "$codex_name" "$codex_name" \ "$worktree" "$state" "$inputs" "$topics" "$require_automation" - test ! -f "$state/merge-graph" || - die "resolve does not reconstruct merge-shaped topic graphs; restack the reviewed graph and rerun Meta/rebuild" - if process_plan "$worktree" "$state" + if process_planned_graph "$worktree" "$state" then - die "the pinned rewrite no longer conflicts; rerun the refresh Action" + candidate=$(assemble_candidate "$worktree" "$state") || + die "the resolved production graph failed candidate validation" + if prepare_unstable_candidate "$worktree" "$state" "$candidate" \ + /dev/stderr + then + die "the pinned rewrite no longer conflicts; rerun the refresh Action" + fi + say + say "Resolution worktree: $worktree" + say "Continue the preserved unstable rebase printed above." + return + fi + + if test -f "$state/merge-graph" + then + write_merge_graph_failure /dev/stderr "$state" "$worktree" + say + say "Resolution worktree: $worktree" + return fi say @@ -10118,6 +10273,42 @@ resolved_rebase_tip () { "$failed_old" "$failed_onto" } +continue_merge_graph_rewrite () { + worktree=$1 + state=$2 + expected=$(state_value "$state" merge-rebase-worktree) + actual=$(CDPATH= cd "$worktree" && pwd -P) || + die "could not resolve merge-graph recovery worktree" + expected=$(CDPATH= cd "$expected" && pwd -P) || + die "could not resolve the preserved merge-graph worktree" + test "$actual" = "$expected" || + die "continue must run in the preserved merge-graph worktree '$expected'" + if rebase_in_progress "$worktree" + then + if test -n "$(git -C "$worktree" -c core.fsmonitor=false ls-files -u)" + then + die "the rebase still has unresolved paths; edit them, git add them, and rerun this command" + fi + if ! continue_rerere_resolution "$worktree" + then + say "Another merge-graph commit conflicts." + say "Edit the conflicted paths, git add them, and rerun:" + say " $(shell_quote "$script_path") continue --worktree $(shell_quote "$worktree")" + return 1 + fi + fi + require_clean_worktree "$worktree" + finish_merge_graph_rebase "$state" + rm -f "$state/failed-old" "$state/failed-owner" \ + "$state/failed-parent" "$state/failed-onto" \ + "$state/failed-commit" + root=$(state_value "$state" recovery-root-worktree) + say "The complete merge graph was rewritten with source refs unchanged." + say "Verify and freeze the stable and unstable candidate with:" + say + say " $(shell_quote "$script_path") publish-topics --worktree $(shell_quote "$root")" +} + continue_rewrite () { worktree= while test $# -gt 0 @@ -10128,11 +10319,14 @@ continue_rewrite () { esac done test -n "$worktree" || die "continue requires --worktree" - state=$(state_path "$worktree") + state=$(recovery_state_path "$worktree") test -d "$state" || die "'$worktree' has no Codex rewrite state" require_state_controller "$state" - test ! -f "$state/merge-graph" || - die "continue does not reconstruct merge-shaped topic graphs; restack the reviewed graph and rerun Meta/rebuild" + if test -f "$state/merge-graph" + then + continue_merge_graph_rewrite "$worktree" "$state" + return + fi if rebase_in_progress "$worktree" then if test -n "$(git -C "$worktree" -c core.fsmonitor=false ls-files -u)" @@ -10227,8 +10421,13 @@ publish_topics () { fi if test -f "$state/pinned-plan-mode" then - prepare_unstable_candidate "$worktree" "$state" "$candidate" \ + if ! prepare_unstable_candidate "$worktree" "$state" "$candidate" \ "$tmp_dir/codex-conflict.md" + then + test ! -s "$tmp_dir/codex-conflict.md" || + cat "$tmp_dir/codex-conflict.md" >&2 + die "unstable candidate recovery stopped; no refs were updated" + fi fi stable_recovery= if test "$(state_value "$state" config-version)" = 2 diff --git a/t/t9905-codex-branch.sh b/t/t9905-codex-branch.sh index e0cfe4a0573540..27a8a2322d5007 100755 --- a/t/t9905-codex-branch.sh +++ b/t/t9905-codex-branch.sh @@ -8770,13 +8770,17 @@ test_expect_success 'pinned plans keep source refs immutable while rebuilding ou git push origin master ) && fetch_all && + snapshot_refs ../pinned-plan.git >before-overlap && test_expect_code 1 sh "$codex_branch" rewrite \ --remote origin --base master --codex codex \ --result overlap-result --updates overlap-updates \ --inputs overlap-inputs --failure overlap-failure \ >overlap.out 2>overlap.err && - test_grep "pinned merge graph overlaps its moved base" \ - overlap.err && + test_grep "Reproduce and preserve this exact topology-aware rebase" \ + overlap-failure && + test_grep "resolve" overlap-failure && + snapshot_refs ../pinned-plan.git >after-overlap && + test_cmp before-overlap after-overlap && ( cd ../pinned-plan-source && git fetch origin meta codex-unstable && @@ -9202,30 +9206,48 @@ test_expect_success 'pinned merge chain rejects a merge-shaped descendant' ' ) ' -test_expect_success 'pinned merge chain rejects current and reverted child-path overlap' ' - for path in child-file child-history - do - fixture=merge-chain-overlap-$path && - clone_pinned_merge_chain "$fixture" && - ( - cd "$fixture-runner" && - root=$(git rev-parse origin/bb/codex/merge-root-unstable) && - grandchild=$(git rev-parse \ - origin/dd/codex/linear-grandchild-unstable) && - if test "$path" = child-history - then - git diff --quiet "$root" "$grandchild" -- "$path" - fi && - git switch master && - write overlapping "$path" && - git add "$path" && - git commit -m "move an overlapping child path" && - git push origin master && - fetch_all && - pinned_merge_chain_reject rejected \ - "pinned merge graph overlaps its moved base" - ) || return 1 - done +test_expect_success 'pinned merge chain preserves recoverable overlap state' ' + clone_pinned_merge_chain merge-chain-overlap && + ( + cd merge-chain-overlap-runner && + git switch master && + write overlapping child-file && + git add child-file && + git commit -m "move an overlapping child path" && + git push origin master && + fetch_all && + fixture_remote=$(git remote get-url origin) && + snapshot_refs "$fixture_remote" >before && + test_expect_code 1 sh "$codex_branch" rewrite \ + --remote origin --base master --codex codex \ + --result rejected-result --updates rejected-updates \ + --inputs rejected-inputs --failure rejected-failure \ + >rejected.out 2>rejected.err && + test_grep "resolve" rejected-failure && + test_grep "No refs were updated" rejected-failure && + snapshot_refs "$fixture_remote" >after-rewrite && + test_cmp before after-rewrite && + inputs_oid=$(git hash-object rejected-inputs) && + sh "$codex_branch" resolve --remote origin --base master \ + --codex codex --inputs-oid "$inputs_oid" \ + --worktree resolution >resolve.out 2>resolve.err && + state=$(git -C resolution rev-parse --path-format=absolute \ + --git-path codex-rewrite-state) && + private=$(cat "$state/unstable/merge-rebase-worktree") && + test -d "$private" && + test -n "$(git -C "$private" ls-files -u)" && + write child "$private/child-file" && + git -C "$private" add child-file && + sh "$codex_branch" continue --worktree "$private" \ + >continue.out 2>continue.err && + test_grep "source refs unchanged" continue.out && + sh "$codex_branch" publish-topics --worktree resolution \ + >publish.out 2>publish.err && + test_grep "Pinned recovery session" publish.out && + test_grep "No refs were updated" publish.out && + snapshot_refs "$fixture_remote" >after-publish && + test_cmp before after-publish + ) ' test_expect_success 'pinned merge chain rejects invalid prerequisite graphs' ' From 0bced9c5876c36ddb4adad87363ed995697dedc2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 17:33:05 -0500 Subject: [PATCH 3/4] codex: classify stacked controller pull requests The state reconciler scans topics by their release lane and controller changes by a direct meta base. A controller PR stacked on another controller branch therefore receives no classification, even though it is part of the same release-controller stack. Record open controller heads rooted at meta, then walk open PRs whose base is one of those heads. Reuse the ordinary controller-state rules at each depth so every stacked change receives exactly one role, build, and lifecycle state. Exercise a three-PR controller stack in the complete-inventory test. The duplicate guard still rejects a PR returned through more than one route. --- .github/CODEX.md | 6 +- .github/workflows/codex-pr-state.sh | 107 +++++++++++++++++++++------- t/t9905-codex-branch.sh | 17 ++++- 3 files changed, 102 insertions(+), 28 deletions(-) diff --git a/.github/CODEX.md b/.github/CODEX.md index 5382843ae96199..0af4a7f333161a 100644 --- a/.github/CODEX.md +++ b/.github/CODEX.md @@ -71,9 +71,9 @@ change to the same topic needs another topic PR. Topic pull requests are review-only: approve them, but do not merge them. Automatic add/alter plans, human remove/reorder plans, and ordinary controller -changes are distinct `meta` changes. Every open PR against `codex`, -`codex-unstable`, or `meta`, plus retained plan history, receives one role, -one build, and one lifecycle state: +changes are distinct `meta` changes. Every open PR rooted at `codex`, +`codex-unstable`, or `meta`, including stacked controller PRs, plus retained +plan history, receives one role, one build, and one lifecycle state: - `kind:review-only`, `kind:auto-plan`, `kind:plan-policy`, or `kind:controller` identifies why the PR exists. diff --git a/.github/workflows/codex-pr-state.sh b/.github/workflows/codex-pr-state.sh index f0796f59e5bcd0..37afc83c8ec62e 100755 --- a/.github/workflows/codex-pr-state.sh +++ b/.github/workflows/codex-pr-state.sh @@ -499,7 +499,42 @@ classify_topics () { done <"$state_dir/$lane-topics" } +classify_controller () { + pull_number=$1 + draft=$2 + head_oid=$3 + current_labels=$4 + review_decision=$5 + merge_state=$6 + + phase=codex:needs-review + test "$draft" != true || phase=codex:draft + test "$draft" = true || + test "$review_decision" != APPROVED || + phase=codex:ready + blocked=false + block_reason=- + if test "$phase" = codex:ready + then + case "$merge_state" in + DIRTY) + blocked=true + block_reason=blocked:merge-conflict + ;; + BLOCKED) + blocked=true + block_reason=blocked:merge-policy + ;; + esac + fi + record_classification "$pull_number" "$head_oid" \ + "$current_labels" kind:controller "$(build_label meta)" \ + "$phase" "$blocked" "$block_reason" +} + classify_meta () { + : >"$state_dir/controller-heads" || + die "could not prepare controller stack inventory" gh pr list --repo "$repository" --state all --base meta \ --limit 1000 \ --json number,state,isDraft,headRefName,headRefOid,body,labels,reviewDecision,mergeStateStatus,statusCheckRollup \ @@ -537,30 +572,10 @@ classify_meta () { codex-plan/*) ;; *) test "$pull_state" = OPEN || continue - phase=codex:needs-review - test "$draft" != true || phase=codex:draft - test "$draft" = true || - test "$review_decision" != APPROVED || - phase=codex:ready - blocked=false - block_reason=- - if test "$phase" = codex:ready - then - case "$merge_state" in - DIRTY) - blocked=true - block_reason=blocked:merge-conflict - ;; - BLOCKED) - blocked=true - block_reason=blocked:merge-policy - ;; - esac - fi - record_classification "$pull_number" "$head_oid" \ - "$current_labels" kind:controller \ - "$(build_label meta)" "$phase" "$blocked" \ - "$block_reason" + classify_controller "$pull_number" "$draft" "$head_oid" \ + "$current_labels" "$review_decision" "$merge_state" + printf '%s\n' "$head_ref" >>"$state_dir/controller-heads" || + die "could not retain controller stack root '$head_ref'" continue ;; esac @@ -621,10 +636,54 @@ classify_meta () { done <"$state_dir/plans" } +classify_stacked_controllers () { + gh pr list --repo "$repository" --state open --limit 1000 \ + --json number,isDraft,baseRefName,headRefName,headRefOid,labels,reviewDecision,mergeStateStatus \ + >"$state_dir/open-pull-requests.json" || + die "could not list open pull requests for controller stacks" + jq -r ' + .[] | + [(.number | tostring), (.isDraft | tostring), + .baseRefName, .headRefName, .headRefOid, + ([.labels[].name] | @json), + (.reviewDecision | + if . == null or . == "" then "-" else . end), + (.mergeStateStatus | + if . == null or . == "" then "-" else . end)] | @tsv + ' "$state_dir/open-pull-requests.json" \ + >"$state_dir/open-pull-requests" || + die "could not parse open pull requests for controller stacks" + : >"$state_dir/stacked-controllers" || + die "could not prepare stacked controller inventory" + while : + do + progress= + while IFS="$tab" read -r pull_number draft base_ref head_ref \ + head_oid current_labels review_decision merge_state + do + test -n "$pull_number" || continue + grep -F -x "$pull_number" "$state_dir/stacked-controllers" \ + >/dev/null 2>&1 && continue + grep -F -x "$base_ref" "$state_dir/controller-heads" \ + >/dev/null 2>&1 || continue + classify_controller "$pull_number" "$draft" "$head_oid" \ + "$current_labels" "$review_decision" "$merge_state" + printf '%s\n' "$pull_number" \ + >>"$state_dir/stacked-controllers" || + die "could not retain stacked controller #$pull_number" + printf '%s\n' "$head_ref" >>"$state_dir/controller-heads" || + die "could not retain controller stack head '$head_ref'" + progress=t + done <"$state_dir/open-pull-requests" + test -n "$progress" || break + done +} + labels >"$state_dir/managed-labels" : >"$state_dir/classifications" snapshot_refs classify_topics codex classify_topics codex-unstable classify_meta +classify_stacked_controllers apply_classifications diff --git a/t/t9905-codex-branch.sh b/t/t9905-codex-branch.sh index 27a8a2322d5007..0825dda0effc0b 100755 --- a/t/t9905-codex-branch.sh +++ b/t/t9905-codex-branch.sh @@ -477,7 +477,19 @@ install_pr_state_gh () { codex|codex-unstable) cat "$FAKE_PR_STATE_DATA/$base.json" ;; - meta) cat "$FAKE_PR_STATE_DATA/plans.json" ;; + meta) + jq '[.[] | select((.baseRefName // "meta") == "meta")]' \ + "$FAKE_PR_STATE_DATA/plans.json" + ;; + "") + jq -s '.[0] + .[1] + + (.[2] | map(. + { + baseRefName:(.baseRefName // "meta")})) | + map(select((.state // "OPEN") == "OPEN"))' \ + "$FAKE_PR_STATE_DATA/codex.json" \ + "$FAKE_PR_STATE_DATA/codex-unstable.json" \ + "$FAKE_PR_STATE_DATA/plans.json" + ;; *) exit 91 ;; esac ;; @@ -10508,16 +10520,19 @@ test_expect_success 'every open Codex pull request receives one exact classifica pr-state-data/codex-unstable.json && jq -n --arg head "$meta" "[ {number:42,state:\"OPEN\",isDraft:true, + baseRefName:\"meta\", headRefName:\"aa/codex-controller-draft\", headRefOid:\$head,body:\"\",labels:[], reviewDecision:\"\",mergeStateStatus:\"UNKNOWN\", statusCheckRollup:[]}, {number:71,state:\"OPEN\",isDraft:false, + baseRefName:\"aa/codex-controller-draft\", headRefName:\"aa/codex-controller-ready\", headRefOid:\$head,body:\"\",labels:[], reviewDecision:\"APPROVED\",mergeStateStatus:\"CLEAN\", statusCheckRollup:[]}, {number:72,state:\"OPEN\",isDraft:false, + baseRefName:\"aa/codex-controller-ready\", headRefName:\"aa/codex-controller-conflict\", headRefOid:\$head,body:\"\",labels:[], reviewDecision:\"APPROVED\",mergeStateStatus:\"DIRTY\", From d13d61a51c570b482d34bb59ab4de0dede1ae209 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 18:10:45 -0500 Subject: [PATCH 4/4] codex: continue across consecutive rerere stops When `git rebase --continue` applies one rerere resolution and immediately reaches another conflict that rerere also stages, Git exits nonzero while the rebase has advanced and no unmerged entries remain. The controller treats that state as an internal failure instead of continuing the supported recovery. Record REBASE_HEAD before each continuation. Retry only when the rebase is still active, no paths are unmerged, and REBASE_HEAD advanced to a different commit. An unchanged failure still stops rather than looping. Extend the rerere fixture to reconstruct two sequential conflicting commits, proving that both resolutions and commit boundaries survive. --- .github/workflows/codex-branch.sh | 9 +++++++++ t/t9905-codex-branch.sh | 23 +++++++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codex-branch.sh b/.github/workflows/codex-branch.sh index 6a07f73c67ca4c..a88052067d5e00 100755 --- a/.github/workflows/codex-branch.sh +++ b/.github/workflows/codex-branch.sh @@ -3726,6 +3726,8 @@ continue_rerere_resolution () { while rebase_in_progress "$worktree" && test -z "$(git -C "$worktree" -c core.fsmonitor=false ls-files -u)" do + before=$(git -C "$worktree" rev-parse --verify REBASE_HEAD \ + 2>/dev/null || :) if ! GIT_COMMITTER_NAME=$bot_name \ GIT_COMMITTER_EMAIL=$bot_email \ GIT_EDITOR=true git -C "$worktree" \ @@ -3740,6 +3742,13 @@ continue_rerere_resolution () { die "git rebase --continue failed without recoverable state" test -n "$(git -C "$worktree" \ -c core.fsmonitor=false ls-files -u)" && return 1 + after=$(git -C "$worktree" rev-parse --verify REBASE_HEAD \ + 2>/dev/null || :) + if test -n "$before" && test -n "$after" && + test "$before" != "$after" + then + continue + fi die "git rebase --continue failed after rerere staged a resolution" fi done diff --git a/t/t9905-codex-branch.sh b/t/t9905-codex-branch.sh index 0825dda0effc0b..c1b55ccfcd737d 100755 --- a/t/t9905-codex-branch.sh +++ b/t/t9905-codex-branch.sh @@ -4364,27 +4364,34 @@ test_expect_success 'codex rerere history can resolve a topic rebase' ' cd rerere-source && git remote add origin ../rerere.git && write base shared && - git add shared && + write base-two shared-two && + git add shared shared-two && install_rerere_train && git commit -m base && + topic_base=$(git rev-parse HEAD) && git switch -c aa/codex/rerere && write topic shared && git add shared && - git commit -m "rerere topic" && + git commit -m "rerere topic one" && + write topic-two shared-two && + git add shared-two && + git commit -m "rerere topic two" && git switch master && write master shared && - git add shared && + write master-two shared-two && + git add shared shared-two && git commit -m "rerere master" && git switch --detach master && test_must_fail git merge --no-ff aa/codex/rerere && write resolved shared && - git add shared && + write resolved-two shared-two && + git add shared shared-two && git commit -m "record resolution" && git branch codex && - git switch -c bb/codex/other aa/codex/rerere^ && + git switch -c bb/codex/other "$topic_base" && write other other-file && git add other-file && git commit -m "independent rerere sibling" && @@ -4408,11 +4415,15 @@ test_expect_success 'codex rerere history can resolve a topic rebase' ' --inputs inputs --failure failure \ >rewrite.out 2>rewrite.err && candidate=$(cat result) && - new_topic=$(find_subject "rerere topic" "$candidate") && + new_topic=$(find_subject "rerere topic two" "$candidate") && + new_topic_one=$(find_subject "rerere topic one" "$candidate") && new_other=$(find_subject "independent rerere sibling" "$candidate") && test -n "$new_topic" && + test -n "$new_topic_one" && test -n "$new_other" && test resolved = "$(git show "$new_topic:shared")" && + test resolved-two = "$(git show "$new_topic:shared-two")" && + has_codex_bot_committer "$new_topic_one" && has_codex_bot_committer "$new_topic" && manifest_has aa/codex/rerere \ "$old_topic" "$new_topic" updates &&