4949# _test_gate_ci_scope_touched, _test_gate_poll_ci_verdict.
5050#
5151# CI-delegated merge gate env vars (all optional, have defaults):
52- # GAAI_CI_TEST_GATE_TIMEOUT_SEC — bounded poll wait (default: 1200,
53- # same order of magnitude as
54- # GAAI_AGENT_HANG_THRESHOLD_SEC)
52+ # GAAI_CI_TEST_GATE_TIMEOUT_SEC — bounded poll wait (default: 2700 =
53+ # 45 min, derived from the CI
54+ # workflow's own declared job
55+ # timeouts along its critical path:
56+ # detect -> max(head, baseline) ->
57+ # test-gate. Covers the workflow's
58+ # full declared execution budget;
59+ # queue latency is absorbed on top
60+ # of it by this same bounded wait)
61+ # GAAI_CI_TEST_GATE_MATERIALIZE_SEC — bounded sub-wait for a workflow run
62+ # to first appear for the expected
63+ # SHA (default: 300 = 5 min). If no
64+ # run has been observed within this
65+ # sub-budget, the poll returns
66+ # "unavailable:no_run" immediately
67+ # instead of waiting out the full
68+ # budget — distinguishes "CI will
69+ # never run" from "CI is still
70+ # running". Once a run has been
71+ # observed in any state, the full
72+ # budget governs.
5573# GAAI_CI_TEST_GATE_POLL_INTERVAL_SEC — sleep between polls (default: 20;
5674# keeps wrapper.log fresh so the
5775# hang-detector never fires on a
@@ -567,26 +585,40 @@ _test_gate_run_with_timeout() {
567585# also sends that SHA to the REST merge endpoint as its atomic TOCTOU backstop.
568586#
569587# Bounded and sleep-based — never busy-waits. Prints exactly one line to
570- # STDOUT: "pass", "fail", "blocked:head_moved", or
571- # "unavailable:<reason>" (reason in timeout|api_error). Progress logging goes
572- # to stderr because stdout is the signal captured by the caller. No scheduler
573- # or notification side effects occur here.
588+ # STDOUT: "pass", "fail", "blocked:head_moved", or "unavailable:<reason>"
589+ # (reason in timeout|api_error|no_run — no_run means the materialize
590+ # sub-budget expired before any workflow run was ever observed for the
591+ # expected SHA; timeout means a run WAS observed but no decisive verdict
592+ # arrived before the full budget expired). Progress logging goes to stderr
593+ # because stdout is the signal captured by the caller. No scheduler or
594+ # notification side effects occur here.
574595_test_gate_poll_ci_verdict () {
575596 local story_id=" $1 " pr_url=" $2 " expected_head_sha=" $3 "
576- local timeout_sec poll_interval api_timeout_sec
577- timeout_sec=$( _test_gate_positive_int " ${GAAI_CI_TEST_GATE_TIMEOUT_SEC:- 1200} " 1200 GAAI_CI_TEST_GATE_TIMEOUT_SEC)
597+ local timeout_sec materialize_sec poll_interval api_timeout_sec
598+ # Default derived from the CI workflow's own declared job timeouts along
599+ # its critical path (detect -> max(head, baseline) -> test-gate) — see the
600+ # file header for the full derivation. Not a wall-clock sample: it stays
601+ # correct independent of runner performance drift.
602+ timeout_sec=$( _test_gate_positive_int " ${GAAI_CI_TEST_GATE_TIMEOUT_SEC:- 2700} " 2700 GAAI_CI_TEST_GATE_TIMEOUT_SEC)
603+ materialize_sec=$( _test_gate_positive_int " ${GAAI_CI_TEST_GATE_MATERIALIZE_SEC:- 300} " 300 GAAI_CI_TEST_GATE_MATERIALIZE_SEC)
578604 poll_interval=$( _test_gate_positive_int " ${GAAI_CI_TEST_GATE_POLL_INTERVAL_SEC:- 20} " 20 GAAI_CI_TEST_GATE_POLL_INTERVAL_SEC)
579605 api_timeout_sec=$( _test_gate_positive_int " ${GAAI_CI_TEST_GATE_API_TIMEOUT_SEC:- 30} " 30 GAAI_CI_TEST_GATE_API_TIMEOUT_SEC)
580606
581- local started_at deadline now remaining call_timeout sleep_for
607+ local started_at deadline materialize_deadline now remaining call_timeout sleep_for
582608 started_at=$( date +%s)
583609 deadline=$(( started_at + timeout_sec ))
610+ materialize_deadline=$(( started_at + materialize_sec ))
611+ local run_observed=0
584612 local api_err_streak=0
585613 local jq_expr=' if (.workflow_runs | length) == 0 then "missing" else (.workflow_runs | sort_by(.created_at) | last | [(.status // "unknown"), (.conclusion // "pending"), (.head_sha // "missing")] | @tsv) end'
586614
587615 while : ; do
588616 now=$( date +%s)
589617 (( now >= deadline )) && break
618+ if [[ " $run_observed " -eq 0 && " $now " -ge " $materialize_deadline " ]]; then
619+ echo " unavailable:no_run"
620+ return 0
621+ fi
590622 remaining=$(( deadline - now ))
591623 call_timeout=" $api_timeout_sec "
592624 (( call_timeout > remaining )) && call_timeout=" $remaining "
@@ -604,46 +636,52 @@ _test_gate_poll_ci_verdict() {
604636 IFS=$' \t ' read -r run_status run_conclusion observed_head_sha <<< " $run_line"
605637 if [[ " $observed_head_sha " != " $expected_head_sha " ]]; then
606638 api_err_streak=$(( api_err_streak + 1 ))
607- elif [[ " $run_status " == " completed" ]]; then
608- # A completed blocking conclusion for the exact pushed SHA is already
609- # decisive. Do not let an unrelated PR-head lookup outage downgrade a
610- # known CI failure into the local fallback path.
611- if [[ " $run_conclusion " != " success" && " $run_conclusion " != " cancelled" ]]; then
612- echo " fail"
613- return 0
614- fi
615-
616- # The workflow lookup may have consumed most of this iteration's
617- # budget. Recompute the remaining wall clock before the second call.
618- # Both success and cancellation need this check: success may authorize
619- # only the current head, while cancellation caused by a superseding
620- # push must report the moved head rather than masquerading as failure.
621- now=$( date +%s)
622- (( now >= deadline )) && break
623- remaining=$(( deadline - now ))
624- local head_call_timeout=" $api_timeout_sec "
625- (( head_call_timeout > remaining )) && head_call_timeout=" $remaining "
626-
627- local current_head_sha head_rc=0
628- current_head_sha=$( _test_gate_run_with_timeout " $head_call_timeout " gh pr view " $pr_url " \
629- --json headRefOid --jq .headRefOid) || head_rc=$?
630- if [[ " $head_rc " -ne 0 || ! " $current_head_sha " =~ ^[0-9a-fA-F]{40}$ ]]; then
631- api_err_streak=$(( api_err_streak + 1 ))
632- elif [[ " $current_head_sha " != " $expected_head_sha " ]]; then
633- echo " blocked:head_moved"
634- return 0
635- elif [[ " $run_conclusion " == " success" ]]; then
636- echo " pass"
637- return 0
639+ else
640+ # A run for the expected SHA has now been seen in SOME state — this
641+ # is "materialized" regardless of whether it is queued, in progress,
642+ # or already completed. From here on the full budget governs.
643+ run_observed=1
644+ if [[ " $run_status " == " completed" ]]; then
645+ # A completed blocking conclusion for the exact pushed SHA is already
646+ # decisive. Do not let an unrelated PR-head lookup outage downgrade a
647+ # known CI failure into the local fallback path.
648+ if [[ " $run_conclusion " != " success" && " $run_conclusion " != " cancelled" ]]; then
649+ echo " fail"
650+ return 0
651+ fi
652+
653+ # The workflow lookup may have consumed most of this iteration's
654+ # budget. Recompute the remaining wall clock before the second call.
655+ # Both success and cancellation need this check: success may authorize
656+ # only the current head, while cancellation caused by a superseding
657+ # push must report the moved head rather than masquerading as failure.
658+ now=$( date +%s)
659+ (( now >= deadline )) && break
660+ remaining=$(( deadline - now ))
661+ local head_call_timeout=" $api_timeout_sec "
662+ (( head_call_timeout > remaining )) && head_call_timeout=" $remaining "
663+
664+ local current_head_sha head_rc=0
665+ current_head_sha=$( _test_gate_run_with_timeout " $head_call_timeout " gh pr view " $pr_url " \
666+ --json headRefOid --jq .headRefOid) || head_rc=$?
667+ if [[ " $head_rc " -ne 0 || ! " $current_head_sha " =~ ^[0-9a-fA-F]{40}$ ]]; then
668+ api_err_streak=$(( api_err_streak + 1 ))
669+ elif [[ " $current_head_sha " != " $expected_head_sha " ]]; then
670+ echo " blocked:head_moved"
671+ return 0
672+ elif [[ " $run_conclusion " == " success" ]]; then
673+ echo " pass"
674+ return 0
675+ else
676+ # A same-head cancelled run is non-decisive: a manual re-run may
677+ # supersede it. Keep polling until a decisive run appears or the
678+ # bounded wait falls back locally.
679+ api_err_streak=0
680+ fi
638681 else
639- # A same-head cancelled run is non-decisive: a manual re-run may
640- # supersede it. Keep polling until a decisive run appears or the
641- # bounded wait falls back locally.
682+ # queued/in_progress/waiting/requested/pending are valid observations.
642683 api_err_streak=0
643684 fi
644- else
645- # queued/in_progress/waiting/requested/pending are valid observations.
646- api_err_streak=0
647685 fi
648686 else
649687 api_err_streak=$(( api_err_streak + 1 ))
@@ -656,9 +694,17 @@ _test_gate_poll_ci_verdict() {
656694
657695 now=$( date +%s)
658696 (( now >= deadline )) && break
697+ if [[ " $run_observed " -eq 0 && " $now " -ge " $materialize_deadline " ]]; then
698+ echo " unavailable:no_run"
699+ return 0
700+ fi
659701 remaining=$(( deadline - now ))
660702 sleep_for=" $poll_interval "
661703 (( sleep_for > remaining )) && sleep_for=" $remaining "
704+ if [[ " $run_observed " -eq 0 ]]; then
705+ local materialize_remaining=$(( materialize_deadline - now ))
706+ (( sleep_for > materialize_remaining )) && sleep_for=" $materialize_remaining "
707+ fi
662708 echo " [TEST-GATE-CI] ${story_id} : waiting on ${pr_url} @${expected_head_sha} (elapsed=$(( now - started_at )) s/${timeout_sec} s)" >&2
663709 sleep " $sleep_for "
664710 done
@@ -718,7 +764,12 @@ _run_merge_test_gate() {
718764 ;;
719765 * )
720766 reason=" ${verdict# unavailable: } "
721- echo " [WARN] ${story_id} handle_commit_phase: CI test-gate result unavailable (${reason} ) — falling back to local gate [class=TEST_GATE_CI_UNAVAILABLE_FALLBACK]"
767+ local reason_detail=" "
768+ case " $reason " in
769+ timeout) reason_detail=" — CI run observed but still in progress at cutoff" ;;
770+ no_run) reason_detail=" — no CI run observed for this SHA" ;;
771+ esac
772+ echo " [WARN] ${story_id} handle_commit_phase: CI test-gate result unavailable (${reason} )${reason_detail} — falling back to local gate [class=TEST_GATE_CI_UNAVAILABLE_FALLBACK]"
722773 _run_deterministic_test_gate " $story_id " " $worktree_path " " $qa_report_path "
723774 return $?
724775 ;;
0 commit comments