diff --git a/.github/CODEX.md b/.github/CODEX.md index 55b4d7f84f3388..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. @@ -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,20 +180,32 @@ 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 -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. +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. 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 ea7a8e60f00a7d..a88052067d5e00 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 ] @@ -1521,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 @@ -3688,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" \ @@ -3702,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 @@ -4287,6 +4334,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 @@ -4332,10 +4413,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" @@ -4388,6 +4472,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 @@ -4404,6 +4498,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" \ @@ -4417,6 +4525,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") @@ -4424,22 +4537,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 () ( @@ -4574,29 +4672,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" || @@ -4722,24 +4824,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 @@ -5305,6 +5448,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 @@ -5376,6 +5558,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" || @@ -5457,35 +5654,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 () { @@ -9208,26 +9378,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 +9418,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 +9459,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 +9530,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 +9648,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 +9797,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 +9829,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 +9997,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 +10042,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 +10051,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 +10154,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 +10176,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 () { @@ -9933,12 +10230,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 + 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 - die "the pinned rewrite no longer conflicts; rerun the refresh Action" + write_merge_graph_failure /dev/stderr "$state" "$worktree" + say + say "Resolution worktree: $worktree" + return fi say @@ -9969,6 +10282,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 @@ -9979,11 +10328,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)" @@ -10078,8 +10430,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/.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 b15a5ed1d5f6c1..c1b55ccfcd737d 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 ;; @@ -4352,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" && @@ -4396,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 && @@ -6346,6 +6369,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 ) @@ -8741,13 +8793,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 && @@ -9173,30 +9229,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' ' @@ -10138,8 +10212,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 +10240,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 @@ -10441,16 +10531,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\", @@ -10560,4 +10653,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