-
Notifications
You must be signed in to change notification settings - Fork 0
944 lines (884 loc) · 51.9 KB
/
Copy pathstuck-job-watchdog.yml
File metadata and controls
944 lines (884 loc) · 51.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
name: Stuck Job Watchdog
# Recovery automation for the known GitHub Actions self-hosted dispatcher bug
# documented at https://github.com/orgs/community/discussions/186811:
# self-hosted runners report Online/Idle but `runner_id` stays at 0 for
# 7+ minutes, so a queued job whose label set is satisfied by an idle runner
# nonetheless waits indefinitely until manually re-run.
#
# FAIL CLOSED (#328). A green run means the audit ran to completion. Every
# input the verdict depends on -- the queued-run listing, the organization
# runner inventory, a per-run job listing, and the cancel-cap state branch --
# fails the job when it cannot be read, instead of logging and exiting 0.
# Before this rule the inventory read was unauthorized on both endpoints the
# watchdog tried and the job still reported success, so a `Performance Numbers`
# run sat queued for ten hours while the automation meant to notice it reported
# green every five minutes.
#
# Runner inventory is ORGANIZATION-scoped and is read with the build-lock
# reader App (`organization_self_hosted_runners: read`), the same credential
# `check-unity-runner-availability` already uses. The job's own GITHUB_TOKEN is
# repository-scoped and can never reach `/orgs/{org}/actions/runner-groups`.
# The repository-level fallback this workflow used to attempt was worse than
# no fallback: this repository registers zero repository-level runners, so the
# call would succeed and report an empty inventory, which reads identically to
# "no runner matches" and still issues no action.
#
# The inventory walk enumerates ALL organization runner groups rather than
# filtering with `visible_to_repository`. That parameter is documented only as a
# "string" with no stated format, and the two plausible readings -- repository
# name and numeric repository id -- are not distinguishable from the docs. Guess
# wrong and the call either 4xxs or returns an empty list, and BOTH land in
# `fail_closed` below, so this job would fail every one of its 288 daily runs
# while reporting a credential or outage problem it does not have. The
# organization registers a single group ("Default") today, so the filter would
# select exactly what the unfiltered call already returns: it is a live failure
# risk in exchange for nothing.
#
# The group names are logged for the day that stops being true. Enumerating all
# groups over-counts capacity if a second, restricted group is ever added, and
# over-counting capacity is what could turn a legitimately queued run into a
# wrongful cancel -- so if a second name ever appears in the summary, narrow this
# walk before trusting it, using an id confirmed against a live response rather
# than against the documentation.
#
# Detection requires ALL of the following to be true:
# * The workflow run is `status: queued` AND older than MIN_QUEUE_AGE_SECONDS
# (default 300s / 5 min; round-2 false-positive guards below -- zero
# in-progress jobs, self-run exclusion, excluded-workflow list -- make
# the previous conservative 600s buffer unnecessary).
# * No job in the run is `status: in_progress` - a run with even one
# in-progress job is by definition holding/using a runner, not
# dispatcher-stuck. This is what prevents false positives for matrix cells
# or jobs waiting while another job from the same run is actively running.
# * At least one job in the run is `status: queued`.
# * At least one ONLINE, NOT-BUSY runner's labels satisfy a queued job's
# label requirements (superset match, case-insensitive on both sides).
# * The run's workflow file is NOT in the exclusion list (`release.yml`
# is hard-excluded by default; additional entries may be added via the
# `WATCHDOG_EXCLUDED_WORKFLOWS` repo variable, whitespace-separated).
# * The run id is NOT the watchdog's own run id.
#
# A queued run that no ONLINE runner can satisfy is NOT dispatcher-stuck --
# cancelling it would destroy work the fleet will pick up as soon as the
# machine reconnects. It is reported in its own step-summary section and as a
# `::warning::` annotation, so an offline runner starving the queue is visible
# on the run page without opening a job log. That is the shape #328 hit: a
# registered-but-offline `fast` runner, invisible because the audit was blind.
#
# Recovery action (per cli/cli#9221 and the gh-run-rerun manual,
# `gh run rerun --failed` cannot be used on a `status: queued` run because
# the run never reached `failed`; the documented workaround is cancel +
# redispatch / cancel + operator-managed re-run):
# * `gh run cancel <id>` kills the stuck dispatch.
# * If the run was triggered by push/schedule/workflow_dispatch on a
# branch (not a tag) AND the workflow file declares `workflow_dispatch:`,
# re-dispatch via REST API
# (`POST repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`).
# * Otherwise (pull_request, tag, no workflow_dispatch trigger), emit a
# clear `GITHUB_STEP_SUMMARY` line instructing the operator to click
# "Re-run all jobs" in the GitHub UI. Do NOT push a commit, comment on
# the PR, or escalate any other automatic action.
#
# Cancel attempts are capped at 2 per run-id per 24h via a small state file
# on the `watchdog-state` orphan branch. The state branch is materialized only
# after a run is classified dispatcher-stuck, so a cycle that finds nothing to
# cancel -- the overwhelming majority -- performs no clone and cannot be failed
# by a state-branch problem it never needed to solve. State is pushed
# immediately after each successful cancel, with a single rebase+retry on push
# failure.
#
# Workflow-level concurrency guarantees only one watchdog instance ever runs;
# newer schedules must not cancel an in-flight audit after it has cancelled a
# stuck run but before it persists the state-branch counter.
on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:
concurrency:
group: stuck-job-watchdog
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
audit-queue:
name: Audit queued runs and recover dispatcher-stuck ones
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Fail closed by construction: this action errors when either secret is
# empty, so a missing credential aborts the audit before it can report a
# verdict it had no way to reach.
- name: Mint the runner-inventory reader token
id: reader_token
timeout-minutes: 2
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }}
private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }}
# Organization-scoped (no `repositories:`) because the runner-group
# endpoints are organization endpoints, and narrowed to the single
# permission this audit needs so the minted token can do nothing else.
owner: ${{ github.repository_owner }}
permission-organization-self-hosted-runners: read
- name: Audit + cancel-and-redispatch
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUNNER_INVENTORY_TOKEN: ${{ steps.reader_token.outputs.token }}
REPO: ${{ github.repository }}
REPO_ID: ${{ github.repository_id }}
OWNER: ${{ github.repository_owner }}
SELF_RUN_ID: ${{ github.run_id }}
STATE_BRANCH: watchdog-state
STATE_DIR: .watchdog-state
MAX_CANCELS_PER_DAY: "2"
MIN_QUEUE_AGE_SECONDS: "300"
DEFAULT_EXCLUDED_WORKFLOWS: "release.yml"
EXTRA_EXCLUDED_WORKFLOWS: ${{ vars.WATCHDOG_EXCLUDED_WORKFLOWS }}
run: |
set -euo pipefail
summary_file="$(mktemp)"
: > "${summary_file}"
log_summary() {
printf '%s\n' "$1" | tee -a "${summary_file}"
}
# Category buckets for the final step-summary table.
healthy_runs_file="$(mktemp)"
stuck_runs_file="$(mktemp)"
excluded_runs_file="$(mktemp)"
starved_runs_file="$(mktemp)"
: > "${healthy_runs_file}"
: > "${stuck_runs_file}"
: > "${excluded_runs_file}"
: > "${starved_runs_file}"
summary_emitted=0
deliberate_exit=0
emit_summary() {
if (( summary_emitted == 1 )); then
return 0
fi
summary_emitted=1
# `|| true`: this runs from an EXIT trap, so a failure here would
# replace the status the job is actually exiting with.
{
echo "## Watchdog summary"
cat "${summary_file}"
echo ""
echo "### Healthy queued (waiting on concurrency / matrix slot)"
if [[ -s "${healthy_runs_file}" ]]; then cat "${healthy_runs_file}"; else echo "_(none)_"; fi
echo ""
echo "### Stuck (auto-cancelled)"
if [[ -s "${stuck_runs_file}" ]]; then cat "${stuck_runs_file}"; else echo "_(none)_"; fi
echo ""
echo "### Starved (no online runner carries the required labels)"
if [[ -s "${starved_runs_file}" ]]; then cat "${starved_runs_file}"; else echo "_(none)_"; fi
echo ""
echo "### Stuck but excluded (operator action needed)"
if [[ -s "${excluded_runs_file}" ]]; then cat "${excluded_runs_file}"; else echo "_(none)_"; fi
} >> "${GITHUB_STEP_SUMMARY}" || true
}
finish() {
deliberate_exit=1
emit_summary
exit "${1:-0}"
}
# Without this, ANY abort `set -e` catches outside the enumerated
# paths -- and this is 500 lines of bash running 288 times a day --
# produces a red run with a completely empty step summary and no
# annotation, which is the worst signal an operator can be handed.
# The trap makes every exit emit the buckets, and labels the ones
# that did not come from `finish` so an unexpected abort is not
# mistaken for a considered verdict.
# Invoked indirectly by the EXIT trap below, so static analysis cannot
# see the call. Both codes are needed because the version numbering
# differs: 0.9 flags the body as unreachable (SC2317), while newer
# builds -- including the one inside the actionlint container CI runs
# -- flag the function as never invoked (SC2329). Note that a comment
# line may not BEGIN with the analyzer's own name, or it is parsed as
# a malformed directive and the whole script stops being checked.
# shellcheck disable=SC2317,SC2329
on_exit() {
local code=$?
if (( code != 0 )) && (( deliberate_exit == 0 )); then
log_summary "ERROR: the audit aborted unexpectedly (exit ${code}); the queue was not fully evaluated."
echo "::error::stuck-job watchdog aborted unexpectedly (exit ${code}); see the job log."
fi
emit_summary
}
trap on_exit EXIT
# The audit could not answer. Never exit 0 from here: a green run has
# to mean the queue was evaluated, not that evaluation was skipped.
fail_closed() {
log_summary "ERROR: ${1}"
log_summary "The audit could not evaluate the queue, so this run fails rather than reporting a verdict it did not reach."
echo "::error::stuck-job watchdog could not complete its audit: ${1}"
finish 1
}
log_summary "## Stuck-job watchdog audit ($(date -u +'%Y-%m-%dT%H:%M:%SZ'))"
log_summary "Repo: ${REPO}"
log_summary "Owner: ${OWNER}"
log_summary "Self run id (will skip): ${SELF_RUN_ID}"
# Build the workflow-file exclusion list (default + repo variable).
declare -A EXCLUDED_BY_FILE=()
for wf in ${DEFAULT_EXCLUDED_WORKFLOWS} ${EXTRA_EXCLUDED_WORKFLOWS:-}; do
[[ -z "${wf}" ]] && continue
# Normalize: strip any leading .github/workflows/ if present.
base="${wf##*/}"
# An empty subscript is a hard bash error, and the strip above turns
# any operator entry ending in `/` into one -- which the very next
# comment invites by talking about directory prefixes. Guarding only
# the read site left the write site able to kill the audit outright,
# every five minutes, on a repo-variable typo.
[[ -z "${base}" ]] && continue
EXCLUDED_BY_FILE["${base}"]=1
done
excluded_list=""
for k in "${!EXCLUDED_BY_FILE[@]}"; do
excluded_list+="${k} "
done
log_summary "Excluded workflows: ${excluded_list:-<none>}"
# ------------------------------------------------------------------
# 1. Enumerate queued runs older than MIN_QUEUE_AGE_SECONDS.
# ------------------------------------------------------------------
now_epoch="$(date -u +%s)"
queued_runs_json="$(mktemp)"
# `gh api --paginate` over the runs endpoint returns one JSON object
# per page; `jq -s '[.[] | .workflow_runs[]?]'` flattens all pages
# into a single array. Equivalent to using `--slurp` on newer gh
# versions but works on all gh versions shipped with ubuntu-latest.
if ! gh api --paginate "repos/${REPO}/actions/runs?status=queued&per_page=100" \
| jq -s '[.[] | (.workflow_runs // [])[]]' > "${queued_runs_json}"; then
fail_closed "failed to list queued runs for ${REPO}."
fi
# `mapfile < <(jq ...)` throws the process substitution's exit status
# away, and `pipefail` does not reach inside one. A single unparseable
# `created_at` would abort jq, yield an empty list, and print
# "Queue is clean" over a genuinely stuck queue -- the exact fail-open
# this rewrite exists to remove. Capture, check, then split.
if ! queued_ids_raw="$(jq -r --argjson now "${now_epoch}" --argjson min "${MIN_QUEUE_AGE_SECONDS}" '
.[]
| select(.created_at != null)
| (.created_at | fromdateiso8601) as $created
| select(($now - $created) >= $min)
| .id
' < "${queued_runs_json}" | sort -u)"; then
fail_closed "could not parse the queued-run listing for ${REPO}; the queue cannot be evaluated."
fi
queued_ids=()
if [[ -n "${queued_ids_raw}" ]]; then
mapfile -t queued_ids <<< "${queued_ids_raw}"
fi
log_summary "Queued runs older than ${MIN_QUEUE_AGE_SECONDS}s: ${#queued_ids[@]}"
if [[ ${#queued_ids[@]} -eq 0 ]]; then
log_summary "Queue is clean. No action."
finish 0
fi
# ------------------------------------------------------------------
# 2. Read the organization runner inventory through the reader App.
# Fail closed on any failure:
# an unreadable inventory cannot distinguish "nothing is stuck"
# from "everything is stuck and we cannot see it".
# ------------------------------------------------------------------
gh_reader() {
GH_TOKEN="${RUNNER_INVENTORY_TOKEN}" gh "$@"
}
# `unique_by(.id)`: `--paginate` concatenates page objects, and the
# live organization returns "Default" twice through this walk. The
# runner dedupe below already made that harmless for the COUNTS, but
# it still cost a redundant API call per duplicate and printed a
# group list that made an operator distrust the summary.
runner_groups_json="$(mktemp)"
if ! gh_reader api --paginate \
"orgs/${OWNER}/actions/runner-groups?per_page=100" \
| jq -s '[.[] | (.runner_groups // [])[] | select(.id != null)] | unique_by(.id)' > "${runner_groups_json}"; then
fail_closed "could not read the organization runner groups."
fi
group_count="$(jq 'length' < "${runner_groups_json}")"
if (( group_count == 0 )); then
fail_closed "the organization reported no runner groups; the inventory cannot be trusted."
fi
log_summary "Organization runner groups: $(jq -r '[.[].name] | join(", ")' < "${runner_groups_json}")"
# Only groups THIS repository may actually use. Counting a runner that
# cannot serve our jobs inflates capacity, and inflated capacity is
# what turns a legitimately queued run into a wrongful cancel: the
# audit would see an idle runner carrying the right labels, conclude
# the dispatcher is stuck, and cancel a run that was correctly waiting.
#
# This reads each group's documented `visibility` field rather than the
# `visible_to_repository` query parameter, which the REST docs type
# only as "string" with no stated format. `all` needs no further call;
# `selected` (and `private`) are resolved against the group's own
# repositories endpoint, which returns repository objects with numeric
# ids -- an unambiguous comparison against `github.repository_id`.
#
# The previous revision walked every group on the stated grounds that
# the organization had exactly one. It has at least two -- `Default`
# and `ambiguous-interactive-organization-builds` -- so that premise
# was simply wrong, and the risk documented in #335 was live rather
# than hypothetical.
# `!= false`, NOT `// true`: jq's `//` treats an explicit `false` as
# absent and yields the right-hand side, so a group that genuinely
# refuses public repositories would read as allowing them -- the
# exact inversion of the check. Caught by the fixture for it.
if ! group_rows_raw="$(jq -r '.[] | "\(.id)\t\(.visibility // "all")\t\(.allows_public_repositories != false)\t\(.name)"' < "${runner_groups_json}")"; then
fail_closed "could not parse the organization runner-group inventory."
fi
group_rows=()
if [[ -n "${group_rows_raw}" ]]; then
mapfile -t group_rows <<< "${group_rows_raw}"
fi
# mapfile, not `while read < <(...)`: the loop body runs `gh`, and a
# child that consumes the loop's stdin silently eats the remaining
# group ids.
runner_group_ids=()
excluded_groups=()
group_repos_json="$(mktemp)"
for group_row in "${group_rows[@]}"; do
IFS=$'\t' read -r group_id group_visibility group_public group_name <<< "${group_row}"
# A group can be `visibility: all` and still refuse PUBLIC
# repositories, and this repository is public -- its runners are
# then unusable capacity, which is the wrongful cancel this walk
# exists to prevent. (Cursor Bugbot.)
#
# Excluded unconditionally rather than only when we know we are
# public, which would need another API call and another failure
# mode. The cost of being wrong is asymmetric: for a PRIVATE
# repository this under-counts capacity, and under-counting can only
# produce a starvation report, never a cancel. Over-counting is what
# cancels a live run.
if [[ "${group_public}" != "true" ]]; then
excluded_groups+=("${group_name} (refuses public repositories)")
continue
fi
if [[ "${group_visibility}" == "all" ]]; then
runner_group_ids+=("${group_id}")
continue
fi
# `private` means "every PRIVATE repository in the organization" --
# not a selected list. This repository is public, so such a group can
# never serve it. Excluded here rather than fetching its repositories
# endpoint, which would answer a question that does not apply and
# could fail the whole audit closed on an error it did not need to
# make. (Cursor Bugbot.)
if [[ "${group_visibility}" == "private" ]]; then
excluded_groups+=("${group_name} (private repositories only)")
continue
fi
if ! gh_reader api --paginate \
"orgs/${OWNER}/actions/runner-groups/${group_id}/repositories?per_page=100" \
| jq -s '[.[] | (.repositories // [])[] | .id | tostring]' > "${group_repos_json}"; then
fail_closed "could not read which repositories may use runner group ${group_name} (${group_id})."
fi
# `--arg`, not `--argjson`: an empty or non-numeric REPO_ID makes
# `--argjson` fail to parse, which under `set -e` aborts the script
# WITHOUT reaching `fail_closed` -- an audit that dies instead of
# reporting. Both sides are compared as strings. (GitHub Copilot.)
if [[ "$(jq --arg want "${REPO_ID}" 'any(.[]; . == $want)' < "${group_repos_json}")" == "true" ]]; then
runner_group_ids+=("${group_id}")
else
excluded_groups+=("${group_name}")
fi
done
if (( ${#excluded_groups[@]} > 0 )); then
# NOT `IFS=', '` with `"${arr[*]}"`: bash joins with only the FIRST
# character of IFS there, so that renders `a,b`. (GitHub Copilot.)
log_summary "Runner groups not usable by ${REPO} (excluded from capacity): $(printf '%s, ' "${excluded_groups[@]}" | sed 's/, $//')"
fi
if (( ${#runner_group_ids[@]} == 0 )); then
fail_closed "no organization runner group is visible to ${REPO}; the inventory cannot be trusted."
fi
runners_json="$(mktemp)"
: > "${runners_json}"
group_runners_json="$(mktemp)"
for group_id in "${runner_group_ids[@]}"; do
if ! gh_reader api --paginate \
"orgs/${OWNER}/actions/runner-groups/${group_id}/runners?per_page=100" \
| jq -s '[.[] | (.runners // [])[]]' > "${group_runners_json}"; then
fail_closed "could not read runners in organization runner group ${group_id}."
fi
cat "${group_runners_json}" >> "${runners_json}"
done
# Labels are compared case-insensitively in both directions: a job
# declaring `windows` and a runner registered as `Windows` are the
# same requirement, and an exact match would silently report every
# such run as unmatched.
inventory_json="$(mktemp)"
jq -s '
[ .[][]
| { id: .id,
name: .name,
online: (.status == "online"),
busy: (.busy == true),
labels: ([(.labels // [])[] | (.name // "") | ascii_downcase | select(. != "")]) }
]
| unique_by(.id)
' < "${runners_json}" > "${inventory_json}"
registered_count="$(jq 'length' < "${inventory_json}")"
online_count="$(jq '[.[] | select(.online)] | length' < "${inventory_json}")"
idle_count="$(jq '[.[] | select(.online and (.busy | not))] | length' < "${inventory_json}")"
log_summary "Runner inventory (groups visible to ${REPO}): ${registered_count} registered, ${online_count} online, ${idle_count} idle."
# ------------------------------------------------------------------
# 3. Classify every queued run. Nothing is cancelled in this pass, so
# the state branch is not needed unless something lands in
# `stuck_candidates`.
# ------------------------------------------------------------------
stuck_candidates="$(mktemp)"
: > "${stuck_candidates}"
# Reads the per-run loop variables directly. Called from FOUR branches:
# healthy-queued and `busy` (the run can still proceed), not-idle (it
# cannot), and dispatcher-stuck (it is about to be cancelled). Only the
# last needs a disposition of its own. No-op when nothing starved.
#
# The disposition is the caller's to state, because it is the one thing
# this function cannot know. It used to hard-code "Not dispatcher-stuck;
# no action." for every caller, so a run with one starved label set and
# another that IS dispatcher-stuck got two contradictory summary lines
# under the same run id -- "no action" immediately above "queued for
# cancel". A watchdog whose whole purpose is a verdict that matches what
# it observed cannot print both. (Cursor Bugbot.)
report_starvation() {
[[ -z "${starved_kind}" ]] && return 0
local detail
local disposition="${1:-Not dispatcher-stuck; no action.}"
if [[ "${starved_kind}" == "offline" ]]; then
detail="every runner carrying [${starved_labels}] is registered but offline"
else
detail="no runner registered in any organization group carries [${starved_labels}]"
fi
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): starved -- ${detail}. ${disposition}"
echo "::warning::run ${run_id} (${run_path_base}) queued past ${MIN_QUEUE_AGE_SECONDS}s: ${detail}. Bring that runner online or fix the label set."
printf '* run %s (%s, event=%s) -- %s -- %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${detail}" "${run_html_url}" >> "${starved_runs_file}"
}
for run_id in "${queued_ids[@]}"; do
# Skip the watchdog's own run id (defense in depth - the watchdog
# workflow file is excluded by name above, but if someone renames
# the file or runs ad-hoc the id check still protects us).
if [[ "${run_id}" == "${SELF_RUN_ID}" ]]; then
log_summary "run ${run_id}: this is the watchdog's own run; skipping."
continue
fi
# Pull the run metadata we need from the cached list (workflow
# file path, event, head_branch).
run_meta="$(
jq -c --argjson id "${run_id}" '
.[]
| select(.id == $id)
| {
path: (.path // ""),
event: (.event // ""),
workflow_id: (.workflow_id // 0),
head_branch: (.head_branch // ""),
html_url: (.html_url // "")
}
' < "${queued_runs_json}" | head -n 1
)"
if [[ -z "${run_meta}" ]]; then
fail_closed "run ${run_id} appeared in the queued listing but carries no metadata; the queue cannot be evaluated."
fi
run_path="$(jq -r '.path' <<< "${run_meta}")"
run_event="$(jq -r '.event' <<< "${run_meta}")"
run_html_url="$(jq -r '.html_url' <<< "${run_meta}")"
run_path_base="${run_path##*/}"
# An empty subscript is a hard bash error, and `path: (.path // "")`
# above already anticipates a null path. A run whose workflow file
# cannot be named cannot be checked against the exclusion list, and
# cancelling is the one irreversible thing this job does, so it is
# reported and left alone rather than risked.
if [[ -z "${run_path_base}" ]]; then
log_summary "run ${run_id} (event=${run_event}): no workflow path reported; cannot check the exclusion list, so taking no action."
echo "::warning::run ${run_id} reported no workflow path; the watchdog cannot classify it and will not cancel it."
printf '* run %s (unknown workflow, event=%s) -- unclassifiable, left alone -- %s\n' "${run_id}" "${run_event}" "${run_html_url}" >> "${excluded_runs_file}"
continue
fi
# Workflow-file exclusion (must NOT cancel release.yml).
if [[ -n "${EXCLUDED_BY_FILE[${run_path_base}]+x}" ]]; then
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): workflow is excluded; operator action needed if stuck."
printf '* run %s (%s, event=%s) -- %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${excluded_runs_file}"
continue
fi
jobs_json="$(mktemp)"
if ! gh api --paginate "repos/${REPO}/actions/runs/${run_id}/jobs?per_page=100" \
| jq -s '[.[] | (.jobs // [])[]]' > "${jobs_json}"; then
fail_closed "failed to list jobs for run ${run_id}; that run could not be evaluated."
fi
in_progress_count="$(jq '[ .[] | select(.status == "in_progress") ] | length' < "${jobs_json}")"
queued_count="$(jq '[ .[] | select(.status == "queued") ] | length' < "${jobs_json}")"
# Classification runs BEFORE the early exits, because starvation has
# to be known on every path that reports one. The `in_progress`
# exit used to `continue` before labels were evaluated at all, so
# the moment a busy runner picked up the healthy cell the run became
# in-progress and a co-resident starved job went silent again for
# the rest of the matrix -- reopening the gap the `busy` fix had
# just closed, in the state that lasts longest. (Cursor Bugbot.)
#
# The exits keep their original order and messages below; only the
# point at which `starved_kind` becomes known has moved.
#
# Resolve each queued job's label set against the inventory. Only
# `idle` is dispatcher-stuck; the other three verdicts are the
# states a naive "no matching idle runner" test conflates, and
# cancelling any of them would destroy work the fleet still
# intends to run:
# idle - an online, not-busy runner carries every label
# busy - a matching runner is online but working
# offline - a matching runner is registered but disconnected
# unregistered - no registered runner carries the label set
# `unregistered` splits again on whether the job asked for
# `self-hosted`. The inventory covers only self-hosted runners, so
# a GitHub-hosted job (`ubuntu-latest`) is unregistered by
# definition and says nothing about our fleet; reporting it as
# starved would fire a warning on every queued hosted run.
# An empty label set would vacuously satisfy `all`, matching every
# runner and manufacturing a dispatcher-stuck verdict, so a job
# whose labels GitHub did not report is dropped rather than
# trusted.
if ! queued_job_labels_raw="$(jq -c '
[ .[] | select(.status == "queued") | [ (.labels // [])[] | ascii_downcase ] ]
| map(select(length > 0))
| unique
| .[]
' < "${jobs_json}")"; then
fail_closed "could not parse the job labels for run ${run_id}; that run could not be evaluated."
fi
queued_job_labels=()
if [[ -n "${queued_job_labels_raw}" ]]; then
mapfile -t queued_job_labels <<< "${queued_job_labels_raw}"
fi
# Deferred, not `continue`d: a run can have BOTH an in-progress job
# and unlabelled queued jobs, and the in-progress verdict is the one
# that describes it. Recording the condition here keeps the exit
# order below identical to what it was before classification moved.
labels_not_usable=0
if (( queued_count == 0 )) || [[ ${#queued_job_labels[@]} -eq 0 ]]; then
labels_not_usable=1
fi
# `match_kind` is the best verdict across ALL of the run's label
# sets and decides the action. `starved_*` is tracked SEPARATELY and
# only from self-hosted sets, because those are the only ones this
# inventory can speak to. Deriving the report from "the first set"
# while deciding from "all sets" made both wrong: a GitHub-hosted
# set sorting first suppressed a real starvation warning entirely,
# and when it did warn it named the hosted label rather than the
# self-hosted one, pointing the operator at a machine that does not
# exist.
match_kind="unregistered"
starved_kind=""
starved_labels=""
for labels_csv in "${queued_job_labels[@]+"${queued_job_labels[@]}"}"; do
kind="$(jq -r --argjson labels "${labels_csv}" '
def satisfies($r): $labels | all(. as $l | $r.labels | index($l) | type == "number");
if any(.[]; .online and (.busy | not) and satisfies(.)) then "idle"
elif any(.[]; .online and satisfies(.)) then "busy"
elif any(.[]; satisfies(.)) then "offline"
else "unregistered" end
' < "${inventory_json}")"
# Precedence idle > busy > offline > unregistered. The loop does
# NOT break on `idle`: breaking meant the remaining label sets were
# never scanned, so a run with one dispatchable cell and one
# starved cell was cancelled with no starvation warning at all --
# and because the stuck sibling keeps matching idle, that starved
# cell could stay invisible across repeated cancel cycles. Scanning
# every set costs one jq call per set and is what lets the verdict
# and the starvation report be independent. (Cursor Bugbot.)
if [[ "${kind}" == "idle" ]]; then
match_kind="idle"
elif [[ "${kind}" == "busy" && "${match_kind}" != "idle" ]]; then
match_kind="busy"
elif [[ "${kind}" == "offline" && "${match_kind}" == "unregistered" ]]; then
match_kind="offline"
fi
# Only a self-hosted set can starve on OUR fleet. Prefer reporting
# an `unregistered` set over an `offline` one: a label set nothing
# carries needs a human, while an offline machine may come back.
if jq -e 'index("self-hosted") | type == "number"' <<< "${labels_csv}" > /dev/null; then
if [[ "${kind}" == "offline" || "${kind}" == "unregistered" ]]; then
if [[ -z "${starved_kind}" ]] \
|| { [[ "${starved_kind}" == "offline" ]] && [[ "${kind}" == "unregistered" ]]; }; then
starved_kind="${kind}"
starved_labels="$(jq -r 'join(", ")' <<< "${labels_csv}")"
fi
fi
fi
done
if (( in_progress_count > 0 )); then
# A run with even one in-progress job is by definition not
# dispatcher-stuck. This covers matrix cells or later jobs
# waiting while another job from the same run is active, and the
# general case of a run that has at least one runner. It is still
# not a reason to hide a sibling nothing can pick up.
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): healthy queued (${in_progress_count} in_progress, ${queued_count} queued -- waiting on concurrency/matrix slot)."
printf '* run %s (%s, event=%s) -- %d in_progress, %d queued\n' "${run_id}" "${run_path_base}" "${run_event}" "${in_progress_count}" "${queued_count}" >> "${healthy_runs_file}"
report_starvation
continue
fi
if (( queued_count == 0 )); then
# No queued jobs at all - the run is in some other transitional
# state, not the dispatcher-stuck pattern.
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): no queued jobs yet (early state); skipping."
continue
fi
if (( labels_not_usable == 1 )); then
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): queued jobs report no labels; not evaluable. No action."
continue
fi
# A busy sibling does not make a starved sibling less starved. The
# `busy` verdict used to `continue` without consulting `starved_kind`,
# so a matrix run with one cell queued behind a busy runner and
# another needing an offline or unregistered one reported only
# "healthy backpressure" -- no warning, no Starved row -- until every
# busy leg finished, which on Unity is hours. That is the #328
# blindness in miniature: a starved job invisible because a sibling
# looks fine. (Cursor Bugbot.)
if [[ "${match_kind}" == "busy" ]]; then
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): every matching runner is online but busy; healthy backpressure. No action."
printf '* run %s (%s, event=%s) -- waiting on a busy matching runner\n' "${run_id}" "${run_path_base}" "${run_event}" >> "${healthy_runs_file}"
report_starvation
continue
fi
# Reaching here implies no queued job asked for `self-hosted`:
# a self-hosted set that is idle breaks out above, one that is busy
# returns above, and one that is offline or unregistered sets
# `starved_kind`. So the only surviving shape is a run this
# inventory genuinely cannot speak to.
#
# The old wording claimed such a run "targets GitHub-hosted
# capacity", which is not the same statement and can be false: a
# registered runner may carry `ubuntu-latest` and simply be offline.
# What is always true is that no queued job ASKED for self-hosted.
if [[ "${match_kind}" != "idle" && -z "${starved_kind}" ]]; then
log_summary "run ${run_id} (${run_path_base}): no queued job requests a self-hosted runner; not evaluable. No action."
continue
fi
if [[ "${match_kind}" != "idle" ]]; then
report_starvation
continue
fi
# Log the verdict HERE, not only when the cancel succeeds. Classification
# and action are separated by the state-branch materialization, which can
# fail closed -- and then the summary said "a stuck run is pending" while
# the Stuck section sat empty, so an operator could not tell WHICH run
# needed attention. Every other verdict already announces itself at the
# point it is reached; this one did not. (Cursor Bugbot.)
# A cancellable sibling does not make a starved one less starved,
# and this is the branch that acts, so it is the one most likely to
# be read.
report_starvation "Another label set in this run IS dispatcher-stuck; the run is queued for cancel below."
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): dispatcher-stuck; queued for cancel -- ${run_html_url}"
printf '%s\n' "${run_meta}" | jq -c --argjson id "${run_id}" '. + {id: $id}' >> "${stuck_candidates}"
done
if [[ ! -s "${stuck_candidates}" ]]; then
log_summary "No dispatcher-stuck run found. No cancel issued."
finish 0
fi
# ------------------------------------------------------------------
# 4. Materialize the cancel-cap state branch. Only reached when a
# cancel is actually about to be issued, so a state-branch problem
# can no longer fail a cycle that had nothing to do -- and when it
# IS needed, an unreadable cap means we must not cancel blind.
# ------------------------------------------------------------------
work_dir="$(mktemp -d)"
pushd "${work_dir}" > /dev/null
# Scope credentials to the exact remote commands that need them; the
# cloned repo keeps a plain HTTPS origin with no tokenized remote.
auth_header="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')"
git_auth() {
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" "$@"
}
remote_url="https://github.com/${REPO}.git"
GIT_AUTHOR_ID=(-c "user.email=actions@github.com" -c "user.name=stuck-job-watchdog")
# Never materialize the default branch. The watchdog reads and writes
# only ${STATE_DIR} on ${STATE_BRANCH}, and checking the default branch
# out just to switch away from it is what failed 13 runs straight on
# 2026-07-29: one committed blob whose bytes disagreed with its
# .gitattributes eol left the pristine clone content-dirty (git skips
# the smudge when the blob already holds CRLF, so the file matched the
# blob while the clean filter hashed to something else), and switching
# trees aborted with "Your local changes to the following files would be
# overwritten by checkout". Cloning the state branch directly retires
# the whole class: no default-branch file is ever written, so none can
# block the switch. ci.yml's `line-endings` job gates the drift itself.
#
# Decoupled probe + bootstrap: ls-remote distinguishes
# "branch missing" (zero rows + exit 0) from "transient fetch
# failure" (non-zero exit). We MUST NOT bootstrap on transient
# failure - that would push-corrupt an existing branch by
# rewriting it as a fresh orphan.
state_branch_probe="$(mktemp)"
if ! git_auth ls-remote --heads "${remote_url}" "${STATE_BRANCH}" > "${state_branch_probe}" 2>/dev/null; then
fail_closed "'git ls-remote --heads ${STATE_BRANCH}' failed; the cancel cap is unreadable and a stuck run is pending."
fi
if [[ -s "${state_branch_probe}" ]]; then
if ! git_auth clone --depth 1 --single-branch --branch "${STATE_BRANCH}" "${remote_url}" repo; then
fail_closed "state branch '${STATE_BRANCH}' exists per ls-remote but the clone failed; the cancel cap is unreadable."
fi
cd repo
log_summary "State branch '${STATE_BRANCH}' checked out."
else
log_summary "State branch '${STATE_BRANCH}' missing -- bootstrapping orphan branch."
mkdir repo
cd repo
git init -q
git checkout -q -b "${STATE_BRANCH}"
git remote add origin "${remote_url}"
mkdir -p "${STATE_DIR}"
touch "${STATE_DIR}/.gitkeep"
git add "${STATE_DIR}/.gitkeep"
git "${GIT_AUTHOR_ID[@]}" commit -m "Initialize watchdog state" || true
if ! git_auth push origin "${STATE_BRANCH}"; then
fail_closed "bootstrap push of '${STATE_BRANCH}' failed; the cancel cap cannot be persisted."
fi
fi
mkdir -p "${STATE_DIR}"
state_dirty=0
persist_state_changes() {
local reason="${1:-state update}"
if (( state_dirty != 1 )); then
return 0
fi
git add "${STATE_DIR}"
if git diff-index --cached --quiet HEAD --; then
if git rev-parse --verify "refs/remotes/origin/${STATE_BRANCH}" > /dev/null 2>&1 \
&& [[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/${STATE_BRANCH}")" ]]; then
state_dirty=0
return 0
fi
else
if ! git "${GIT_AUTHOR_ID[@]}" commit -m "Watchdog: ${reason} at $(date -u +'%Y-%m-%dT%H:%M:%SZ')"; then
log_summary "WARN: state commit failed; counters may double-count next run."
return 1
fi
fi
if git_auth push origin "${STATE_BRANCH}"; then
log_summary "State branch updated (${reason})."
state_dirty=0
return 0
fi
log_summary "WARN: state push failed; attempting fetch + rebase + retry."
if git_auth fetch origin "+${STATE_BRANCH}:refs/remotes/origin/${STATE_BRANCH}" \
&& git rebase "refs/remotes/origin/${STATE_BRANCH}" \
&& git_auth push origin "${STATE_BRANCH}"; then
log_summary "State branch updated on second attempt (${reason}, after rebase)."
state_dirty=0
return 0
fi
log_summary "WARN: state push failed twice; counters may double-count next run."
return 1
}
is_nonnegative_integer() {
[[ "${1:-}" =~ ^[0-9]+$ ]]
}
# ------------------------------------------------------------------
# 5. Cancel each dispatcher-stuck run, capped at MAX_CANCELS_PER_DAY
# per run-id, then re-dispatch where a safe path exists.
# ------------------------------------------------------------------
# mapfile, not `while read < file`: `gh run cancel` inherits the
# loop's stdin and would swallow the remaining candidates.
mapfile -t stuck_candidate_lines < "${stuck_candidates}"
for candidate in "${stuck_candidate_lines[@]}"; do
run_id="$(jq -r '.id' <<< "${candidate}")"
run_event="$(jq -r '.event' <<< "${candidate}")"
run_workflow_id="$(jq -r '.workflow_id' <<< "${candidate}")"
run_head_branch="$(jq -r '.head_branch' <<< "${candidate}")"
run_html_url="$(jq -r '.html_url' <<< "${candidate}")"
run_path_base="$(jq -r '.path' <<< "${candidate}")"
run_path_base="${run_path_base##*/}"
state_file="${STATE_DIR}/${run_id}.json"
cancels=0
last_cancel=0
if [[ -f "${state_file}" ]]; then
state_ok=1
state_content="$(cat "${state_file}" 2>/dev/null)" || state_ok=0
if [[ ${state_ok} -eq 1 ]]; then
parsed_cancels="$(jq -r '.cancels // .reruns // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0
parsed_last="$(jq -r '.last_cancel // .last_rerun // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0
fi
if [[ ${state_ok} -eq 1 ]]; then
if is_nonnegative_integer "${parsed_cancels}" && is_nonnegative_integer "${parsed_last}"; then
cancels="${parsed_cancels}"
last_cancel="${parsed_last}"
else
state_ok=0
fi
fi
if [[ ${state_ok} -ne 1 ]]; then
log_summary "run ${run_id}: state file corrupt; resetting."
else
log_summary "run ${run_id}: loaded cancel state (cancels=${cancels}, last_cancel=${last_cancel})."
fi
fi
# Reset counter if last action was >24h ago.
if (( now_epoch - last_cancel > 86400 )); then
cancels=0
fi
if (( cancels >= MAX_CANCELS_PER_DAY )); then
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): cancel cap (${MAX_CANCELS_PER_DAY}/24h) reached; skipping. Manual intervention required."
printf '* run %s (%s, event=%s) -- cap reached, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}"
continue
fi
log_summary "run ${run_id} (${run_path_base}, event=${run_event}): dispatcher-stuck; cancelling (attempt $((cancels + 1))/${MAX_CANCELS_PER_DAY})."
if ! gh run cancel "${run_id}" --repo "${REPO}" 2>&1 | tee -a "${summary_file}"; then
log_summary "run ${run_id}: 'gh run cancel' failed; will try again next cycle."
continue
fi
cancels=$((cancels + 1))
printf '{"cancels": %d, "last_cancel": %d}\n' "${cancels}" "${now_epoch}" > "${state_file}"
git add "${state_file}"
state_dirty=1
persist_state_changes "record cancel for run ${run_id}" || true
# Decide redispatch path based on event + workflow_dispatch trigger.
workflow_def="$(mktemp)"
workflow_supports_dispatch=0
if gh api "repos/${REPO}/actions/workflows/${run_workflow_id}" > "${workflow_def}" 2>/dev/null; then
workflow_path="$(jq -r '.path // ""' < "${workflow_def}")"
if [[ -n "${workflow_path}" ]]; then
# Look at the contents of the workflow file to see if it
# declares `workflow_dispatch:` (the trigger we need to use
# the dispatches REST API).
workflow_file_raw="$(mktemp)"
if gh api "repos/${REPO}/contents/${workflow_path}" --jq '.content' 2>/dev/null \
| base64 -d > "${workflow_file_raw}" 2>/dev/null; then
if grep -Eq '^[[:space:]]*workflow_dispatch:' "${workflow_file_raw}"; then
workflow_supports_dispatch=1
fi
fi
fi
fi
case "${run_event}" in
push|schedule|workflow_dispatch)
if [[ -n "${run_head_branch}" && ${workflow_supports_dispatch} -eq 1 ]]; then
log_summary "run ${run_id}: re-dispatching workflow ${run_workflow_id} on ref '${run_head_branch}'."
if gh api -X POST "repos/${REPO}/actions/workflows/${run_workflow_id}/dispatches" \
-f "ref=${run_head_branch}" 2>&1 | tee -a "${summary_file}"; then
printf '* run %s (%s, event=%s) -- cancelled and re-dispatched on %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_head_branch}" >> "${stuck_runs_file}"
else
log_summary "run ${run_id}: re-dispatch failed; operator action: ${run_html_url}"
printf '* run %s (%s, event=%s) -- cancelled; re-dispatch FAILED, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}"
fi
else
log_summary "run ${run_id}: workflow does not support workflow_dispatch on ref '${run_head_branch}'; operator action: click 'Re-run all jobs' at ${run_html_url}"
printf \
'* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' \
"${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}"
fi
;;
pull_request|pull_request_target)
# No safe API path: the dispatches endpoint cannot re-trigger
# a pull_request run, and pushing a no-op commit to the head
# ref would tamper with the PR. Operator-visible cancel +
# explicit step-summary instruction is the supported path.
log_summary "run ${run_id}: pull_request-triggered; operator action: click 'Re-run all jobs' at ${run_html_url}"
printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}"
;;
*)
log_summary "run ${run_id}: event '${run_event}' has no automatic recovery path; operator action: click 'Re-run all jobs' at ${run_html_url}"
printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}"
;;
esac
done
# ------------------------------------------------------------------
# 6. Commit + push state changes with a single rebase+retry.
# ------------------------------------------------------------------
persist_state_changes "final state sync" || true
popd > /dev/null
finish 0