@@ -35,6 +35,15 @@ output_file="$2"
3535echo " Submitted batch job $job_id "
3636echo " Monitoring output file: $output_file "
3737
38+ # Put the one thing a reader needs on the run's summary page. Without this,
39+ # learning why a job failed means opening a log of tens of thousands of lines --
40+ # and an infrastructure fault looks exactly like a test failure until you do.
41+ # Silent when not running under Actions.
42+ ci_summary () {
43+ [ -n " ${GITHUB_STEP_SUMMARY:- } " ] || return 0
44+ printf ' %b\n' " $1 " >> " $GITHUB_STEP_SUMMARY "
45+ }
46+
3847# Robustly check SLURM job state using squeue with sacct fallback.
3948# Returns the state string (PENDING, RUNNING, COMPLETED, FAILED, etc.)
4049# or "UNKNOWN" if both commands fail.
@@ -70,9 +79,13 @@ get_job_state() {
7079 echo " UNKNOWN"
7180}
7281
73- # Check if a state is terminal (job is done, for better or worse)
74- # PREEMPTED is intentionally excluded: with --requeue the job restarts under
75- # the same job ID and we must keep monitoring rather than exiting early.
82+ # Check if a state is terminal (job is done, for better or worse).
83+ # PREEMPTED is handled separately (below): Phoenix preempts 'embers' jobs with
84+ # PreemptMode=CANCEL, not REQUEUE (verified via `scontrol show config`), so a
85+ # preempted job is killed outright and never restarts under the same ID.
86+ # --requeue is a no-op for it. It is surfaced via PREEMPT_EXIT so the submit
87+ # wrapper can resubmit a fresh job instead of failing the CI step.
88+ PREEMPT_EXIT=76
7689is_terminal_state () {
7790 case " $1 " in
7891 COMPLETED|FAILED|CANCELLED|CANCELLED+|TIMEOUT|OUT_OF_MEMORY|NODE_FAIL|BOOT_FAIL|DEADLINE|REVOKED)
@@ -82,18 +95,68 @@ is_terminal_state() {
8295 esac
8396}
8497
98+ # Optionally bound how long a job may sit un-started in the queue. On the
99+ # preemptible Phoenix 'embers' QOS a job routinely stays PENDING for hours and
100+ # needs most of the job-level `timeout-minutes` (480m) window to backfill onto a
101+ # free node; that job timeout is the real backstop. Default to 0 (wait
102+ # indefinitely, up to the job timeout) so ordinary queue pressure does not turn
103+ # otherwise-healthy jobs into red CI. Set SLURM_MAX_QUEUE_SECONDS>0 to opt into
104+ # an earlier queue-starvation cutoff where the scheduler is not preemptible.
105+ : " ${SLURM_MAX_QUEUE_SECONDS:= 0} " # 0 = wait indefinitely (job timeout is the backstop)
106+ # Reject a non-integer override rather than silently skipping the budget.
107+ if ! [[ " $SLURM_MAX_QUEUE_SECONDS " =~ ^[0-9]+$ ]]; then
108+ echo " ERROR: SLURM_MAX_QUEUE_SECONDS must be a non-negative integer (seconds), got '$SLURM_MAX_QUEUE_SECONDS '" >&2
109+ exit 1
110+ fi
111+ # How long to wait between status polls and between output-stabilization
112+ # checks. Overridable so tests can exercise this script without sleeping
113+ # through it; CI leaves it at the default.
114+ : " ${MFC_MONITOR_POLL_SECONDS:= 5} "
115+
116+ queue_start=$( date +%s)
117+
118+ abort_queue_starvation () {
119+ local waited=" $1 "
120+ echo " ##[error]SLURM job $job_id did not start within ${waited} s (SLURM_MAX_QUEUE_SECONDS=$SLURM_MAX_QUEUE_SECONDS )."
121+ echo " QUEUE STARVATION: the cluster scheduler could not start this job in time."
122+ echo " This is an infrastructure / queue-availability problem, NOT a code or test failure."
123+ echo " Cancelling the queued job so it does not keep holding a CI runner slot."
124+ scancel " $job_id " 2> /dev/null || true
125+ exit 75 # EX_TEMPFAIL — distinguishes queue starvation from a real test failure
126+ }
127+
85128# Wait for file to appear, using robust state checking.
86- # Never give up due to transient squeue/sacct failures — the CI job timeout
87- # is the ultimate backstop.
129+ # Never give up due to transient squeue/sacct failures — the queue-wait budget
130+ # above (or the CI job timeout) is the ultimate backstop.
88131echo " Waiting for job to start..."
89132unknown_count=0
90133while [ ! -f " $output_file " ]; do
91134 state=$( get_job_state " $job_id " )
92135
136+ # A started job (RUNNING/COMPLETING) whose output file is merely NFS-delayed
137+ # is exempt, so work in progress is never killed here.
138+ if [ " $SLURM_MAX_QUEUE_SECONDS " -gt 0 ]; then
139+ case " $state " in
140+ RUNNING|COMPLETING) ;;
141+ * )
142+ waited=$(( $(date +% s) - queue_start ))
143+ if [ " $waited " -ge " $SLURM_MAX_QUEUE_SECONDS " ]; then
144+ abort_queue_starvation " $waited "
145+ fi
146+ ;;
147+ esac
148+ fi
149+
93150 case " $state " in
94- PENDING|CONFIGURING|PREEMPTED)
151+ PREEMPTED)
152+ # Preempted before producing output (embers, PreemptMode=CANCEL): the job
153+ # is dead and will not requeue. Signal the caller to resubmit a fresh job.
154+ echo " [$( date +%H:%M:%S) ] Job $job_id PREEMPTED before start/output — signaling resubmit."
155+ exit " $PREEMPT_EXIT "
156+ ;;
157+ PENDING|CONFIGURING)
95158 unknown_count=0
96- sleep 5
159+ sleep " $MFC_MONITOR_POLL_SECONDS "
97160 ;;
98161 RUNNING|COMPLETING)
99162 unknown_count=0
@@ -106,7 +169,7 @@ while [ ! -f "$output_file" ]; do
106169 if [ $(( unknown_count % 12 )) -eq 1 ]; then
107170 echo " Warning: Could not query job $job_id state (SLURM may be temporarily unavailable)..."
108171 fi
109- sleep 5
172+ sleep " $MFC_MONITOR_POLL_SECONDS "
110173 ;;
111174 * )
112175 # Terminal state — job finished without creating output
@@ -115,7 +178,7 @@ while [ ! -f "$output_file" ]; do
115178 exit 1
116179 fi
117180 # Unrecognized state, keep waiting
118- sleep 5
181+ sleep " $MFC_MONITOR_POLL_SECONDS "
119182 ;;
120183 esac
121184done
@@ -138,6 +201,12 @@ last_heartbeat=$(date +%s)
138201while true ; do
139202 state=$( get_job_state " $job_id " )
140203
204+ if [ " $state " = " PREEMPTED" ]; then
205+ # Preempted mid-run (embers, PreemptMode=CANCEL): dead, will not requeue.
206+ echo " [$( date +%H:%M:%S) ] Job $job_id PREEMPTED mid-run — signaling resubmit."
207+ exit " $PREEMPT_EXIT "
208+ fi
209+
141210 if is_terminal_state " $state " ; then
142211 echo " [$( date +%H:%M:%S) ] Job $job_id reached terminal state: $state "
143212 break
@@ -150,11 +219,17 @@ while true; do
150219 last_heartbeat=$current_time
151220 fi
152221
153- sleep 5
222+ sleep " $MFC_MONITOR_POLL_SECONDS "
154223done
155224
156- # Give tail a moment to flush the final lines, then stop streaming.
225+ # Give tail a moment to flush the final lines, then stop streaming. Whether it
226+ # was still alive decides how much needs reprinting below: if it streamed the
227+ # whole job, printing the file again just doubles every log.
157228sleep 2
229+ streamed_ok=0
230+ if kill -0 " ${tail_pid} " 2> /dev/null; then
231+ streamed_ok=1
232+ fi
158233kill " ${tail_pid} " 2> /dev/null || true
159234tail_pid=" "
160235
@@ -174,13 +249,24 @@ if [ -f "$output_file" ]; then
174249 if [ $same_count -ge 2 ]; then
175250 break
176251 fi
177- sleep 5
252+ sleep " $MFC_MONITOR_POLL_SECONDS "
178253 done
179254fi
180255
256+ # Reprint only what streaming may have missed. `tail -f` above already emitted
257+ # the whole file as it was written, so cat'ing it again duplicated every job's
258+ # output -- measured at 3 copies of each line on a GPU job, and 65,000 lines of
259+ # offload diagnostics repeated for a single fault. The reprint exists solely as
260+ # a safety net for a tail that died mid-job, so it is bounded when tail survived
261+ # and complete only when it did not.
181262echo " "
182- echo " === Final output ==="
183- cat " $output_file "
263+ if [ " ${streamed_ok:- 0} " -eq 1 ]; then
264+ echo " === Final output (tail; the full log streamed above) ==="
265+ tail -n " ${MFC_MONITOR_FINAL_LINES:- 40} " " $output_file "
266+ else
267+ echo " === Final output (streaming stopped early; reprinting in full) ==="
268+ cat " $output_file "
269+ fi
184270
185271# Check exit status with sacct fallback
186272exit_code=" "
@@ -207,9 +293,32 @@ if [ -z "$exit_code" ]; then
207293 exit 1
208294fi
209295
296+ # The preflight's node-fault verdict comes back as the job's own exit code.
297+ # Relay it verbatim: flattening it to 1 would leave the submit wrapper unable to
298+ # tell "this node is unusable" (exclude it and try again) from "the tests
299+ # failed" (report it).
300+ faulted_node=$( grep -oE ' MFC_FAULT_NODE=[^ ]+' " $output_file " 2> /dev/null | tail -n1 | cut -d= -f2 || true)
301+
302+ case " $exit_code " in
303+ 77:* )
304+ echo " Job $job_id failed preflight: the node is unusable — signaling caller to exclude it and resubmit."
305+ ci_summary " ### :warning: Infrastructure fault — not a code or test failure\n\nNode \` ${faulted_node:- unknown} \` could not run MFC (job \` $job_id \` ). It is excluded and the job resubmitted elsewhere.\n"
306+ monitor_success=1
307+ exit 77
308+ ;;
309+ esac
310+
210311# Check if job succeeded
211312if [ " $exit_code " != " 0:0" ]; then
212313 echo " ERROR: Job $job_id failed with exit code $exit_code "
314+ # A GPU memory fault explains itself in a block the test harness prints; lift
315+ # it onto the summary page so the faulting kernel and source line are visible
316+ # without opening the log at all.
317+ if grep -q ' GPU fault summary' " $output_file " 2> /dev/null; then
318+ ci_summary " ### GPU memory fault\n\n\`\`\` \n$( grep -A6 ' GPU fault summary' " $output_file " | head -8 | sed ' s/`/' " '" ' /g' ) \n\`\`\` \n"
319+ else
320+ ci_summary " ### Job \` $job_id \` failed (exit $exit_code )\n\n\`\`\` \n$( tail -n 15 " $output_file " | sed ' s/`/' " '" ' /g' ) \n\`\`\` \n"
321+ fi
213322 exit 1
214323fi
215324
0 commit comments