autonomous-sdlc-harness 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,8 @@
12
12
  # [--park-loop-clear] [--chain <n>] [--repo <root>]
13
13
  # remote-run.sh pause <branch> [--repo <root>]
14
14
  # remote-run.sh warm [--repo <root>]
15
- # remote-run.sh stop <branch> [--actor <login>] [--repo <root>]
15
+ # remote-run.sh stop <branch> [--actor <login>] [--note <text>] [--pr <n>]
16
+ # [--branch-gone] [--repo <root>]
16
17
  # remote-run.sh status <branch> [--repo <root>]
17
18
  # remote-run.sh sync <branch> [--repo <root>]
18
19
  # remote-run.sh fetch <branch> <out_dir> [--repo <root>]
@@ -273,7 +274,8 @@
273
274
  # whose head is <branch> when origin's <branch> carries
274
275
  # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
275
276
  # the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
276
- # task prompt, else nowhere. The label `STATE_LABEL_PREFIX<state>` replaces any
277
+ # task prompt, else nowhere (`stop`'s `--pr` and `--branch-gone` change this
278
+ # for its own report: its paragraph). The label `STATE_LABEL_PREFIX<state>` replaces any
277
279
  # other state label on that issue and that pull request, each when known; the
278
280
  # label is a view, and the run list stays the authority. The state map:
279
281
  # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
@@ -287,12 +289,16 @@
287
289
  # `<!-- sdlc-harness event=<event> branch=<branch> -->`. The pause reason and
288
290
  # reset come from the registry record, read only when the registry file exists.
289
291
  # `parked` instead posts one comment per open question — `open_questions_in`
290
- # over `$root`'s state directory, ascending — carrying `question_<n>.md` whole,
291
- # cut at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
292
- # the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, a
293
- # comment whose first line is `COMMAND_HANDLE answer <n>` (`<n>` optional when
294
- # one question is open) and whose following lines are the answer; the marker
295
- # adds `question=<n>`. With no question open it posts the one notice. The label
292
+ # over `$root`'s state directory, ascending — carrying `question_<n>.md` less
293
+ # every line naming its own `answer_<n>.md` (another index's line stays), cut
294
+ # at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
295
+ # the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, the
296
+ # comment's one answer instruction: a comment whose first line is
297
+ # `COMMAND_HANDLE answer <n>` (`<n>` optional when one question is open) and
298
+ # whose following lines are the answer; then a fenced copy block of that form,
299
+ # `COMMAND_HANDLE answer <n>` over `<your answer>`; the marker adds
300
+ # `question=<n>`. With no question open it posts nothing, sets no label and
301
+ # prints one `::error::` line naming the branch. The label
296
302
  # is set once per target, not per question. On a public repository a question
297
303
  # comment and its answer are public, as the artifact already is
298
304
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
@@ -356,8 +362,9 @@
356
362
  # and outside the sending-verb gate: it gates itself after reading the event,
357
363
  # so a refusal can still be replied to. The workflow's `if:` only saves a
358
364
  # runner; every rule below holds without it. It handles `GITHUB_EVENT_NAME`
359
- # `issue_comment` and `pull_request_review` (THE REVIEW, below); any other
360
- # name, or an event file it cannot read, exits 1.
365
+ # `issue_comment`, `pull_request_review` (THE REVIEW, below), and `issues`,
366
+ # `pull_request` and `delete` (THE CLOSE, below); any other name, or an event
367
+ # file it cannot read, exits 1.
361
368
  # It reads `.action`, `.comment.body`, `.issue.number`,
362
369
  # `.issue.pull_request.url`, `.sender.login` and `.sender.type`, each by `jq`
363
370
  # into a variable (data, never shell source), plus `trigger`'s environment.
@@ -429,7 +436,25 @@
429
436
  # way on. On 0 with no other question open, a reply that the run resumes and
430
437
  # `running` on its issue and pull request; with others open, a reply naming
431
438
  # them, and the label stays `parked`. An answer becomes a comment on the item,
432
- # public on a public repository, as the question already is.
439
+ # public on a public repository, as the question already is. A STOPPED RUN IS
440
+ # NAMED `stopped`: a stop leaves the bundle as it was, so after `pause`,
441
+ # `resume`, `answer` and `clear` read the state, `control_state_word_var` asks
442
+ # `remote_branch_stopped` (one extra run listing) whether a `running`,
443
+ # `parked`, `park_loop` or `paused` run was stopped, and every reply names it
444
+ # `stopped`, with the state underneath where the way on depends on it. The
445
+ # states each verb accepts are unchanged, except that `pause` refuses a
446
+ # stopped `running` run, whose cancelled job is still finishing: `resume`
447
+ # still resumes a stopped `paused` run, and on a stopped `parked` run the
448
+ # answer, on a stopped `park_loop` run the clear, is its resume. `status` is
449
+ # READ-ONLY and accepted in every state, `none` and a run in flight included:
450
+ # it reads the state (`control_state_var`, `control_state_word_var`) and the
451
+ # first `- [ ] ` entry of the flow-progress ledger on origin's tip with the
452
+ # `## ` heading above it, and replies once — the state by `CS_WORD` with its
453
+ # pause reason, the state underneath a stopped one, an expired bundle's detail,
454
+ # the next ledger entry (or that every entry is ticked, or that the ledger
455
+ # could not be read), each open question with its `answer <n>` form, and the
456
+ # latest run's URL. It sets no label and runs no child verb but `fetch`; a
457
+ # failed state read is a refusal and exit 3.
433
458
  # THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
434
459
  # `.review.body`, `.review.id`, `.review.html_url`, `.review.submitted_at`,
435
460
  # `.pull_request.number`, `.pull_request.head.ref`,
@@ -451,7 +476,10 @@
451
476
  # `your review was collected`, naming the state and saying the next round
452
477
  # starts by itself when that run finishes, plus the state's way on: `answer
453
478
  # <n>` for `parked`, `clear` for `park_loop`, `resume` for any `paused` but
454
- # `usage`, and for `usage` that it resumes after the reset. The review stays on
479
+ # `usage`, and for `usage` that it resumes after the reset. A run of those
480
+ # states that `remote_branch_stopped` finds stopped is named `stopped`, and its
481
+ # way on is the one `resume` names for it: `answer <n>`, `clear`, `resume`, or
482
+ # for `running` `resume` once the cancelled job has ended. The review stays on
455
483
  # the pull request, and a later round collects it. Settled: a round is CUMULATIVE,
456
484
  # built by `round_collect` in a fresh file under `RUNNER_TEMP` from ONE
457
485
  # paginated `pulls/<n>/reviews` and ONE paginated `pulls/<n>/comments` listing
@@ -505,11 +533,59 @@
505
533
  # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
506
534
  # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
507
535
  # `@<login>: `<verb>` was not run: <reason>. <way on>`.
508
- # 0 handled (replied), or ignored
509
- # 1 neither an `issue_comment` nor a `pull_request_review` event, or the event could not be read
536
+ # THE CLOSE. Whatever the run's phase, it is stopped when its issue is closed,
537
+ # its pull request closed or merged, or its branch deleted; the routing is by
538
+ # event name, never by verb, so a comment naming `close` is not one. `issues`
539
+ # reads `.action`, `.issue.number`, `.sender.login`, `.sender.type`;
540
+ # `pull_request` reads `.action`, `.pull_request.number`,
541
+ # `.pull_request.merged`, `.pull_request.head.ref`,
542
+ # `.pull_request.head.repo.full_name` and the sender; `delete` reads `.ref`,
543
+ # `.ref_type` and the sender — each by `event_field`. Ignored, with one line
544
+ # and no `gh` call: an action other than `closed`; a pull request whose head
545
+ # repository is not `GITHUB_REPOSITORY`; a `ref_type` other than `branch`, or
546
+ # a ref `valid_branch` rejects. A CLOSE IS NEVER REPLIED TO: each gate below,
547
+ # in order, is one line and exit 0, with no comment, label or dispatch —
548
+ # 1. `HARNESS_REMOTE_STOP` is set
549
+ # 2. `forge_on` fails
550
+ # 3. the actor: on `issues` and `pull_request`, `authorise_actor` refused
551
+ # (statuses 1–3), the line naming the login and `AUTH_WHY`; on `delete`, only a `Bot`
552
+ # sender `trigger_bot_listed` does not list, since deleting a branch
553
+ # already needs write access
554
+ # 4. no branch: an issue's from `control_issue_branch_var` (no genuine start
555
+ # comment), a pull request's head ref, a deletion's `.ref`; then not a
556
+ # valid branch name
557
+ # 5. `hr_branch_is_protected` does not answer 1
558
+ # 6. on `pull_request`, the head branch absent on origin (`remote_branch_exists`
559
+ # answers 1): GitHub closed the pull request because the branch was
560
+ # deleted, and the `delete` event's job stops the run; an `ls-remote` that
561
+ # cannot answer is one line and proceeds
562
+ # There is no ledger-at-tip check (`forge_recognised`): a merged or deleted
563
+ # branch may no longer carry one, and a listed `harness run <b>` run is the
564
+ # proof. The state is read by `control_state_var`: only `running`, `parked`,
565
+ # `park_loop` and `paused` are acted on; `completed`, `failed` and `none` are
566
+ # `left alone: the run on <b> is <state>`, and one `remote_branch_stopped`
567
+ # already finds stopped is `already stopped` — each one line and exit 0. Then
568
+ # `stop <b> --actor <login> --note <note>` runs as a child (`control_child`),
569
+ # the note `Stopped because @<login> closed issue #<n>.`, `… closed pull
570
+ # request #<n>.` or `… merged pull request #<n>.` (both with `--pr <n>`), or
571
+ # `… deleted the branch `<b>`.` (with `--branch-gone`). Every note but the
572
+ # deletion's adds that the workflow runs and their `harness-state` artifacts
573
+ # are kept; a closed pull request's `stopped` comment says to reopen it, or to
574
+ # use the issue, before commenting `@sdlc-harness resume`. The runs and
575
+ # artifacts are kept. Reopening the issue or the pull
576
+ # request does nothing. A `pull_request` close job runs the pull request's
577
+ # merge-commit copy of the workflow, as a review job does
578
+ # (docs/github-integration-research.md -> C2); the script stays the default
579
+ # branch's.
580
+ # 0 handled (replied), or ignored; a close stopped, or ignored (one line)
581
+ # 1 not an event named above, or the event could not be read
510
582
  # 2 refused (replied)
511
583
  # 3 a `gh` step failed: the reply could not be posted (an `::error::`
512
- # line), or the action failed and was replied to
584
+ # line), or the action failed and was replied to; for a close, an
585
+ # `::error::` line and no reply, because the item is closed: the stop
586
+ # child failed (naming `CHILD_LAST`), or the repository name, the
587
+ # closer's permission, the issue's comments or the run's state could
588
+ # not be read
513
589
  # 4 a review's round could not be placed; nothing was dispatched (replied)
514
590
  #
515
591
  # `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
@@ -606,12 +682,17 @@
606
682
  # run URL, no dispatch
607
683
  # decision continue refused, in this order: `HARNESS_REMOTE_STOP` set (one
608
684
  # `paused`); the branch stopped (one log line, no
609
- # notification — the user asked for it); chain unreadable
685
+ # notification — the user asked for it); the branch absent
686
+ # on origin (one log line, no notification; an
687
+ # `ls-remote` that cannot answer is one line and proceeds,
688
+ # since the dispatch then fails loudly); chain unreadable
610
689
  # or `chain + 1` over `HARNESS_MAX_CHAIN` (one `failed`);
611
690
  # otherwise re-dispatched. A dispatch that fails is one
612
691
  # `paused` naming its error
613
- # decision wait-poller the branch stopped: one log line. Otherwise `gh
614
- # workflow enable WORKFLOW_RESUME_FILE`; a failed enable
692
+ # decision wait-poller the branch stopped, or absent on origin: one log
693
+ # line (an `ls-remote` that cannot answer is one line and
694
+ # proceeds). Otherwise `gh workflow enable
695
+ # WORKFLOW_RESUME_FILE`; a failed enable
615
696
  # (the job token's enable permission is unverified) is one
616
697
  # `paused` saying auto-resume is unavailable
617
698
  # decision stop nothing: job mode has already notified
@@ -647,8 +728,10 @@
647
728
  # says `status: paused` / `pause_reason: usage` it is waiting, because the job
648
729
  # uploads before its `continue` step enables the poller; an artifact lookup or
649
730
  # download that fails for it is one line and waiting. For a `completed` run,
650
- # skipped, not waiting: a stopped branch, a bundle that cannot be downloaded
651
- # (one line), and anything but `status: paused` / `pause_reason: usage` with an
731
+ # skipped, not waiting: a stopped branch, a branch absent on origin (its state
732
+ # entry dropped, so a later branch of the name starts clean; an `ls-remote`
733
+ # that cannot answer is one line and proceeds), a bundle that cannot be
734
+ # downloaded (one line), and anything but `status: paused` / `pause_reason: usage` with an
652
735
  # integer `usage_resume_at`, and a run whose state entry says `notified`. Due
653
736
  # (reset passed): re-dispatched under the same chain limit — a refusal is one
654
737
  # `failed` and not waiting; a success drops the branch's state entry. A
@@ -777,11 +860,24 @@
777
860
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
778
861
  # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
779
862
  # and exits 3, so running `stop` again is the remedy. (4) A complete stop is then
780
- # reported as `report stopped` with the note `Stopped by @<actor>.` under
781
- # `--actor` (a login, the trigger's shape plus an optional `[bot]`; anything else
782
- # is a usage error), else one naming a local stop; a partial stop reports
783
- # nothing. The cancelled job's own `failed` is then posted nowhere, because
784
- # `report` finds the branch stopped.
863
+ # reported as `report stopped` with the note `--note <text>` when given, else
864
+ # `Stopped by @<actor>.` under `--actor` (a login, the trigger's shape plus an
865
+ # optional `[bot]`; anything else is a usage error), else one naming a local
866
+ # stop; a partial stop reports nothing. Under `--pr <n>` that report's comment
867
+ # and label go to pull request <n> whatever its state — the open-PR lookup
868
+ # cannot find a closed one — and the issue is still labelled. The cancelled
869
+ # job's own `failed` is then posted nowhere, because `report` finds the branch
870
+ # stopped. `--branch-gone` is for a branch GitHub no longer has: (1)'s marker
871
+ # is dispatched with `--ref` set to GitHub's own default branch (read as `warm`
872
+ # reads it; a failed read exits 3 before anything is sent) and still carries
873
+ # `-f branch=<branch>`, so its `harness stop <branch>` title is unchanged;
874
+ # (2)'s listing also reads `headSha,createdAt`; (4) reads the issue from the
875
+ # task prompt at the newest `harness run <branch>` run's `headSha` through the
876
+ # contents API rather than from `origin/<branch>` (a failed read is one line
877
+ # and no issue), and its text says the branch was deleted, so the run cannot
878
+ # be resumed, and that its workflow runs and artifacts are kept. That read
879
+ # rests on GitHub serving a commit no branch points at, which is unverified
880
+ # (`docs/github-run-control.md` -> `## 8. What is not verified here`).
785
881
  #
786
882
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
787
883
  # --json defaultBranchRef`), which may differ from the configured
@@ -1103,7 +1199,7 @@ LEGACY_TRIGGER_LABEL='harness'
1103
1199
  TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1104
1200
  WORKFLOW_CONTROL_FILE='harness-control.yml'
1105
1201
  COMMAND_HANDLE='@sdlc-harness'
1106
- COMMAND_VERBS='answer pause resume stop clear'
1202
+ COMMAND_VERBS='answer pause resume stop clear status'
1107
1203
  COMMENT_MARKER='<!-- sdlc-harness'
1108
1204
  REVIEW_ROUND_STATE='changes_requested'
1109
1205
  STATE_LABEL_PREFIX='sdlc-harness: '
@@ -1165,7 +1261,7 @@ usage() {
1165
1261
  echo "usage: remote-run.sh dispatch <branch> --engine <task|user_review|docs> [--resume none|answer|pause] [--answers-from <clar_dir> --indexes \"<n> ...\"] [--park-loop-clear] [--chain <n>] [--repo <root>]" >&2
1166
1262
  echo " remote-run.sh pause <branch> [--repo <root>]" >&2
1167
1263
  echo " remote-run.sh warm [--repo <root>]" >&2
1168
- echo " remote-run.sh stop <branch> [--actor <login>] [--repo <root>]" >&2
1264
+ echo " remote-run.sh stop <branch> [--actor <login>] [--note <text>] [--pr <n>] [--branch-gone] [--repo <root>]" >&2
1169
1265
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
1170
1266
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
1171
1267
  echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
@@ -1282,6 +1378,7 @@ source_arg=""
1282
1378
  reviewers_arg=""
1283
1379
  allow_no_run=0
1284
1380
  pr_arg=""
1381
+ branch_gone=0
1285
1382
 
1286
1383
  while [ "$#" -gt 0 ]; do
1287
1384
  case "$1" in
@@ -1314,7 +1411,7 @@ while [ "$#" -gt 0 ]; do
1314
1411
  [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1315
1412
  review_file="$2"; shift 2 ;;
1316
1413
  --note)
1317
- [ "$verb" = report ] || usage "$1 is a report option"
1414
+ [ "$verb" = report ] || [ "$verb" = stop ] || usage "$1 is a report or stop option"
1318
1415
  [ "$#" -ge 2 ] || usage "$1 needs a value"
1319
1416
  report_note="$2"; shift 2 ;;
1320
1417
  --actor)
@@ -1333,12 +1430,15 @@ while [ "$#" -gt 0 ]; do
1333
1430
  [ "$verb" = review ] || usage "$1 is a review option"
1334
1431
  allow_no_run=1; shift ;;
1335
1432
  --pr)
1336
- [ "$verb" = collect ] || usage "$1 is a collect option"
1433
+ [ "$verb" = collect ] || [ "$verb" = stop ] || usage "$1 is a collect or stop option"
1337
1434
  [ "$#" -ge 2 ] || usage "$1 needs a value"
1338
1435
  case "$2" in
1339
1436
  ''|*[!0-9]*|0*) usage "--pr needs a positive pull request number" ;;
1340
1437
  esac
1341
1438
  pr_arg="$2"; shift 2 ;;
1439
+ --branch-gone)
1440
+ [ "$verb" = stop ] || usage "$1 is a stop option"
1441
+ branch_gone=1; shift ;;
1342
1442
  -*)
1343
1443
  usage "unknown option '$1'" ;;
1344
1444
  *)
@@ -1634,29 +1734,48 @@ verb_pause() {
1634
1734
  echo "remote-run.sh: dispatched action=pause for $branch"
1635
1735
  }
1636
1736
 
1637
- verb_warm() {
1638
- local default_branch
1737
+ # github_default_branch_var — GITHUB_DEFAULT_BRANCH, GitHub's own default
1738
+ # branch; exits 3 when it cannot be read.
1739
+ GITHUB_DEFAULT_BRANCH=""
1740
+ github_default_branch_var() {
1639
1741
  gh_call repo view --json defaultBranchRef || gh_fail "reading GitHub's default branch failed"
1640
- default_branch=$(printf '%s' "$GH_OUT" | jq -r '.defaultBranchRef.name // empty' 2>/dev/null)
1641
- if ! valid_branch "$default_branch"; then
1742
+ GITHUB_DEFAULT_BRANCH=$(printf '%s' "$GH_OUT" | jq -r '.defaultBranchRef.name // empty' 2>/dev/null)
1743
+ if ! valid_branch "$GITHUB_DEFAULT_BRANCH"; then
1642
1744
  GH_ERR="no defaultBranchRef.name in its output"
1643
1745
  gh_fail "reading GitHub's default branch failed"
1644
1746
  fi
1747
+ }
1748
+
1749
+ verb_warm() {
1750
+ local default_branch
1751
+ github_default_branch_var
1752
+ default_branch="$GITHUB_DEFAULT_BRANCH"
1645
1753
  gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$default_branch" -f "action=warm" -f "branch=$default_branch" || gh_fail "warm-up on '$default_branch' failed"
1646
1754
  echo "remote-run.sh: dispatched action=warm on $default_branch"
1647
1755
  }
1648
1756
 
1649
1757
  verb_stop() {
1650
- local ids id failed=0 first_err="" registry stopped_at
1758
+ local ids id failed=0 first_err="" registry stopped_at marker_ref="$branch" fields=databaseId,displayTitle,status
1759
+ local gone="" gone_sha="" note
1651
1760
  stopped_at=$(date +%s)
1652
- gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" -f "action=stop" -f "branch=$branch" || gh_fail "stop marker for '$branch' failed, nothing cancelled"
1761
+ if [ "$branch_gone" -eq 1 ]; then
1762
+ github_default_branch_var
1763
+ marker_ref="$GITHUB_DEFAULT_BRANCH"
1764
+ gone=gone
1765
+ fields="$fields,headSha,createdAt"
1766
+ fi
1767
+ gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$marker_ref" -f "action=stop" -f "branch=$branch" || gh_fail "stop marker for '$branch' failed, nothing cancelled"
1653
1768
  echo "remote-run.sh: dispatched the action=stop marker for $branch"
1654
1769
 
1655
- gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json databaseId,displayTitle,status --limit 100 || gh_fail "listing the runs of '$branch' failed"
1770
+ gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json "$fields" --limit 100 || gh_fail "listing the runs of '$branch' failed"
1656
1771
  ids=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" '.[] | select(.displayTitle == $t and (.status == "queued" or .status == "in_progress" or .status == "waiting")) | .databaseId' 2>/dev/null) || {
1657
1772
  GH_ERR="its run list is not the expected JSON"
1658
1773
  gh_fail "listing the runs of '$branch' failed"
1659
1774
  }
1775
+ if [ -n "$gone" ]; then
1776
+ gone_sha=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" \
1777
+ '[.[] | select(.displayTitle == $t)] | max_by(.createdAt // "") | .headSha // empty' 2>/dev/null) || gone_sha=""
1778
+ fi
1660
1779
  for id in $ids; do
1661
1780
  if gh_call run cancel "$id"; then
1662
1781
  echo "remote-run.sh: asked GitHub to cancel run $id of $branch"
@@ -1677,11 +1796,14 @@ verb_stop() {
1677
1796
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
1678
1797
  fi
1679
1798
  echo "remote-run.sh: stopped $branch"
1680
- if [ -n "$actor_arg" ]; then
1681
- forge_report stopped "$branch" "Stopped by @$actor_arg."
1799
+ if [ -n "$report_note" ]; then
1800
+ note="$report_note"
1801
+ elif [ -n "$actor_arg" ]; then
1802
+ note="Stopped by @$actor_arg."
1682
1803
  else
1683
- forge_report stopped "$branch" "Stopped from a local \`remote-run.sh stop\`."
1804
+ note="Stopped from a local \`remote-run.sh stop\`."
1684
1805
  fi
1806
+ forge_report stopped "$branch" "$note" "$pr_arg" "$gone" "$gone_sha"
1685
1807
  }
1686
1808
 
1687
1809
  # list_runs — the branch's runs of the workflow into GH_OUT; exits 3 on failure.
@@ -2434,6 +2556,27 @@ remote_branch_stopped() {
2434
2556
  [ "$verdict" = stopped ]
2435
2557
  }
2436
2558
 
2559
+ # remote_branch_exists <branch> — 0 when origin lists refs/heads/<branch>, 1
2560
+ # when `ls-remote` answers 2 (no such head), 2 on any other failure, with
2561
+ # REMOTE_BRANCH_ERR holding its exit status and first stderr line.
2562
+ REMOTE_BRANCH_ERR=""
2563
+ remote_branch_exists() {
2564
+ local errfile status
2565
+ REMOTE_BRANCH_ERR=""
2566
+ errfile=$(mktemp) || { REMOTE_BRANCH_ERR="mktemp failed"; return 2; }
2567
+ git -C "$root" ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>"$errfile"
2568
+ status=$?
2569
+ case "$status" in
2570
+ 0) rm -f "$errfile"; return 0 ;;
2571
+ 2) rm -f "$errfile"; return 1 ;;
2572
+ esac
2573
+ IFS= read -r REMOTE_BRANCH_ERR <"$errfile" || :
2574
+ [ -n "$REMOTE_BRANCH_ERR" ] || REMOTE_BRANCH_ERR="(no stderr)"
2575
+ REMOTE_BRANCH_ERR="git ls-remote exited $status: $REMOTE_BRANCH_ERR"
2576
+ rm -f "$errfile"
2577
+ return 2
2578
+ }
2579
+
2437
2580
  # redispatch <engine> <chain> — `dispatch <branch> --engine <engine> --resume
2438
2581
  # pause --chain <chain>` for the global branch, in a subshell so dispatch's own
2439
2582
  # exits stay its own. On failure REDISPATCH_ERR holds its first stderr line.
@@ -2495,6 +2638,11 @@ continue_redispatch() {
2495
2638
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
2496
2639
  2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: whether the run was stopped could not be checked ($GH_ERR)."; return 0 ;;
2497
2640
  esac
2641
+ remote_branch_exists "$branch"
2642
+ case $? in
2643
+ 1) echo "remote-run.sh: $branch no longer exists on origin; not re-dispatched"; return 0 ;;
2644
+ 2) echo "remote-run.sh: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2645
+ esac
2498
2646
  if ! max_chain_var; then
2499
2647
  notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer." "Not continued: the repository variable \`HARNESS_MAX_CHAIN\` ('$MAX_CHAIN') is not a non-negative integer."
2500
2648
  return 0
@@ -2519,6 +2667,11 @@ continue_wait_poller() {
2519
2667
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
2520
2668
  2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit was not scheduled: whether the run was stopped could not be checked ($GH_ERR). $USAGE_RESUME_NOTE"; return 0 ;;
2521
2669
  esac
2670
+ remote_branch_exists "$branch"
2671
+ case $? in
2672
+ 1) echo "remote-run.sh: $branch no longer exists on origin; the resume poller is not enabled"; return 0 ;;
2673
+ 2) echo "remote-run.sh: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2674
+ esac
2522
2675
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
2523
2676
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
2524
2677
  else
@@ -2684,6 +2837,11 @@ poll_branch() {
2684
2837
  0) echo "remote-run.sh: poll: $branch is stopped ($STOPPED_LINE); skipped"; return 1 ;;
2685
2838
  2) echo "remote-run.sh: poll: the stop-marker check for $branch failed ($GH_ERR); skipped"; return 1 ;;
2686
2839
  esac
2840
+ remote_branch_exists "$branch"
2841
+ case $? in
2842
+ 1) poll_state_drop "$branch"; echo "remote-run.sh: poll: $branch no longer exists on origin; skipped"; return 1 ;;
2843
+ 2) echo "remote-run.sh: poll: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2844
+ esac
2687
2845
  if [ "$state" != completed ]; then
2688
2846
  # Never dispatched from here: the job's own `continue` step enables the
2689
2847
  # poller after its upload, and a later tick sees the run completed.
@@ -3667,7 +3825,7 @@ forge_marker() {
3667
3825
  FORGE_ISSUE=""
3668
3826
  FORGE_TRIGGER_LABEL=""
3669
3827
  forge_issue_var() {
3670
- local state_rel rel prompt line rest num prefix label
3828
+ local state_rel rel prompt
3671
3829
  FORGE_ISSUE=""
3672
3830
  FORGE_TRIGGER_LABEL=""
3673
3831
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
@@ -3677,6 +3835,41 @@ forge_issue_var() {
3677
3835
  fi
3678
3836
  rel=$(hr_task_prompt_rel "$state_rel" "$1")
3679
3837
  prompt=$(git -C "$root" show "refs/remotes/origin/$1:$rel" 2>/dev/null) || return 0
3838
+ forge_provenance_parse "$prompt"
3839
+ }
3840
+
3841
+ # forge_issue_at_commit_var <branch> <sha> — forge_issue_var for a branch
3842
+ # GitHub no longer has: the task prompt is read through the contents API at
3843
+ # <sha>. A failed read is one line and leaves both empty.
3844
+ forge_issue_at_commit_var() {
3845
+ local state_rel path
3846
+ FORGE_ISSUE=""
3847
+ FORGE_TRIGGER_LABEL=""
3848
+ if ! [[ "${2-}" =~ ^[0-9a-f]{7,40}$ ]]; then
3849
+ echo "remote-run.sh: report: no \`harness run $1\` run names a commit; the issue of $1 is unknown" >&2
3850
+ return 1
3851
+ fi
3852
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3853
+ if [ -z "$state_rel" ]; then
3854
+ echo "remote-run.sh: cannot resolve the state directory under '$root'" >&2
3855
+ return 1
3856
+ fi
3857
+ GH_ERR="its path could not be encoded"
3858
+ path=$(jq -rn --arg p "$(hr_task_prompt_rel "$state_rel" "$1")" '$p | split("/") | map(@uri) | join("/")') || path=""
3859
+ if [ -z "$path" ] || ! gh_call api "repos/$FORGE_REPO/contents/$path?ref=$2" -H "Accept: application/vnd.github.raw"; then
3860
+ echo "remote-run.sh: report: reading the task prompt of $1 at $2 failed: $GH_ERR" >&2
3861
+ return 1
3862
+ fi
3863
+ forge_provenance_parse "$GH_OUT"
3864
+ }
3865
+
3866
+ # forge_provenance_parse <prompt> — FORGE_ISSUE and FORGE_TRIGGER_LABEL from
3867
+ # the last provenance line of <prompt>, matched against this repository's own
3868
+ # issue URL only.
3869
+ forge_provenance_parse() {
3870
+ local prompt="$1" line rest num prefix label
3871
+ FORGE_ISSUE=""
3872
+ FORGE_TRIGGER_LABEL=""
3680
3873
  prefix="Started from $FORGE_SERVER/$FORGE_REPO/issues/"
3681
3874
  while IFS= read -r line; do
3682
3875
  case "$line" in
@@ -3801,10 +3994,20 @@ forge_utc() {
3801
3994
 
3802
3995
  # forge_question_body <out_file> <branch> <n> <open_count> <clar_dir> [<note>] —
3803
3996
  # write question <n>'s park comment, without its marker, into <out_file>: the
3804
- # file's bytes, cut at the last whole line within QUESTION_COMMENT_MAX_BYTES
3805
- # when it is over it (measured in bytes; `${#…}` counts characters).
3997
+ # file's bytes less every line naming `answer_<n>.md` (the file channel's own
3998
+ # answer line; another index's stays), cut at the last whole line within
3999
+ # QUESTION_COMMENT_MAX_BYTES when it is over it (measured in bytes; `${#…}`
4000
+ # counts characters), then the answer form and its copy block. Leaves
4001
+ # <out_file>.q and, when cut, <out_file>.cut for the caller to remove.
3806
4002
  forge_question_body() {
3807
- local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last
4003
+ local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last rc
4004
+ [ -r "$qfile" ] || return 1
4005
+ # -a and LC_ALL=C: a question is text whatever bytes it holds. Status 1 is
4006
+ # every line dropped, not a failure.
4007
+ rc=0
4008
+ LC_ALL=C grep -avF "answer_$n.md" "$qfile" >"$out.q" || rc=$?
4009
+ [ "$rc" -le 1 ] || return 1
4010
+ qfile="$out.q"
3808
4011
  size=$(wc -c <"$qfile") || return 1
3809
4012
  size=$((size))
3810
4013
  {
@@ -3826,17 +4029,19 @@ forge_question_body() {
3826
4029
  "$HR_REMOTE_CLARIFY_DIR" "$br" "$n" "$STATE_ARTIFACT_NAME"
3827
4030
  printf '\nAnswer with a comment whose first line is `%s answer %s` and whose following lines are your answer.' "$COMMAND_HANDLE" "$n"
3828
4031
  [ "$count" -ne 1 ] || printf ' This is the only open question, so `%s` may be left out: `%s answer`.' "$n" "$COMMAND_HANDLE"
3829
- printf '\n'
4032
+ printf '\n\n```\n%s answer %s\n<your answer>\n```\n' "$COMMAND_HANDLE" "$n"
3830
4033
  [ -z "$note" ] || printf '\n%s\n' "$note"
3831
4034
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3832
4035
  } >>"$out"
3833
4036
  }
3834
4037
 
3835
- # forge_report <event> <branch> [<note>] — one lifecycle comment (on `parked`,
3836
- # one per open question) and the state label, by the target rule above.
3837
- # Always 0.
4038
+ # forge_report <event> <branch> [<note> [<pr> [gone [<sha>]]]] — one lifecycle
4039
+ # comment (on `parked`, one per open question) and the state label, by the
4040
+ # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4041
+ # that is the target whatever its state; `gone`, the branch deleted on GitHub,
4042
+ # its issue read from the task prompt at <sha>. Always 0.
3838
4043
  forge_report() {
3839
- local event="$1" br="$2" note="${3-}" state reason="" resume_at="" when registry_file
4044
+ local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
3840
4045
  local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
3841
4046
  case "$event" in
3842
4047
  parked|park_loop) state=parked ;;
@@ -3872,10 +4077,22 @@ forge_report() {
3872
4077
  || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
3873
4078
  fi
3874
4079
 
3875
- forge_fetch_branch "$br"
3876
- forge_issue_var "$br" || FORGE_ISSUE=""
3877
- forge_pr_var "$br" || FORGE_PR=""
3878
- if [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
4080
+ [ "$event" = stopped ] || { pr=""; gone=""; }
4081
+ if [ -n "$gone" ]; then
4082
+ forge_issue_at_commit_var "$br" "$gone_sha" || FORGE_ISSUE=""
4083
+ else
4084
+ forge_fetch_branch "$br"
4085
+ forge_issue_var "$br" || FORGE_ISSUE=""
4086
+ fi
4087
+ if [ -n "$pr" ]; then
4088
+ FORGE_PR="$pr"
4089
+ elif [ -n "$gone" ]; then
4090
+ # GitHub closes a pull request whose head is deleted, so none is open.
4091
+ FORGE_PR=""
4092
+ else
4093
+ forge_pr_var "$br" || FORGE_PR=""
4094
+ fi
4095
+ if [ -z "$pr" ] && [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
3879
4096
  echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
3880
4097
  FORGE_PR=""
3881
4098
  fi
@@ -3905,8 +4122,6 @@ forge_report() {
3905
4122
  fi ;;
3906
4123
  park_loop)
3907
4124
  text="The harness run on \`$br\` is on hold: it parked on its questions again and again without progress. Comment \`${COMMAND_HANDLE} clear\` to clear the hold and let it continue." ;;
3908
- parked)
3909
- text="The harness run on \`$br\` is waiting for an answer. Its questions are in the run's \`$STATE_ARTIFACT_NAME\` artifact." ;;
3910
4125
  resumed)
3911
4126
  text="The harness run on \`$br\` resumed." ;;
3912
4127
  failed)
@@ -3917,7 +4132,14 @@ forge_report() {
3917
4132
  text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, re-apply the label \`$trigger_label\` to this issue; that starts a new run, on the next indexed branch."
3918
4133
  fi ;;
3919
4134
  stopped)
3920
- text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes." ;;
4135
+ if [ -n "$gone" ]; then
4136
+ text="The harness run on \`$br\` was stopped: its branch was deleted, so the run cannot be resumed. Its workflow runs and their artifacts are kept."
4137
+ elif [ -n "$pr" ]; then
4138
+ # Only a close passes <pr>, and every command on a closed pull request is refused.
4139
+ text="The harness run on \`$br\` was stopped. While the branch exists, reopen this pull request and comment \`${COMMAND_HANDLE} resume\` here, or comment it on the run's issue, to continue it from its committed ledger. A merged pull request cannot be reopened; after a merge, use the issue."
4140
+ else
4141
+ text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes."
4142
+ fi ;;
3921
4143
  round)
3922
4144
  text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
3923
4145
  esac
@@ -3926,6 +4148,10 @@ forge_report() {
3926
4148
  if [ "$event" = parked ]; then
3927
4149
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3928
4150
  [ -z "$state_rel" ] || open_questions_in "$root/${state_rel%/}"
4151
+ if [ -z "$OPEN_QUESTIONS" ]; then
4152
+ echo "::error::remote-run.sh: report: $br was classified parked with no open question; nothing posted"
4153
+ return 0
4154
+ fi
3929
4155
  fi
3930
4156
 
3931
4157
  tmp="${RUNNER_TEMP-}"
@@ -3943,7 +4169,7 @@ forge_report() {
3943
4169
  else
3944
4170
  echo "remote-run.sh: report: cannot write question $n's comment for #$target; not posted" >&2
3945
4171
  fi
3946
- rm -f "$file" "$file.cut"
4172
+ rm -f "$file" "$file.cut" "$file.q"
3947
4173
  else
3948
4174
  echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
3949
4175
  fi
@@ -4256,6 +4482,62 @@ control_state_var() {
4256
4482
  return 0
4257
4483
  }
4258
4484
 
4485
+ # control_branch_stopped <state> — 0 when <state> is unfinished (`running`,
4486
+ # `parked`, `park_loop`, `paused`) and `remote_branch_stopped` finds
4487
+ # CONTROL_BRANCH stopped; else 1, after one stderr line when the check failed.
4488
+ # The run list, not the bundle, records a stop.
4489
+ control_branch_stopped() {
4490
+ local status=0
4491
+ case "$1" in
4492
+ running|parked|park_loop|paused) ;;
4493
+ *) return 1 ;;
4494
+ esac
4495
+ remote_branch_stopped "$CONTROL_BRANCH" || status=$?
4496
+ case "$status" in
4497
+ 0) return 0 ;;
4498
+ 1) ;;
4499
+ *) echo "remote-run.sh: control: whether \`$CONTROL_BRANCH\` is stopped could not be read ($GH_ERR); the reply names the state as read" >&2 ;;
4500
+ esac
4501
+ return 1
4502
+ }
4503
+
4504
+ # control_state_word_var — called right after a successful control_state_var:
4505
+ # CS_STOPPED 1 when control_branch_stopped answers 0 for CS_STATE, else 0;
4506
+ # CS_WORD `stopped` then, else CS_STATE. Every reply names the state by
4507
+ # CS_WORD; every test of which states a verb accepts reads CS_STATE.
4508
+ CS_STOPPED=0; CS_WORD=""
4509
+ control_state_word_var() {
4510
+ CS_STOPPED=0
4511
+ CS_WORD="$CS_STATE"
4512
+ if control_branch_stopped "$CS_STATE"; then
4513
+ CS_STOPPED=1
4514
+ CS_WORD=stopped
4515
+ fi
4516
+ return 0
4517
+ }
4518
+
4519
+ # control_stopped_refuse — the refusal of a verb on a stopped run (CS_STOPPED
4520
+ # 1): names it `stopped`, the state underneath where the way on depends on it,
4521
+ # and the way on that state takes.
4522
+ control_stopped_refuse() {
4523
+ local open=""
4524
+ case "$CS_STATE" in
4525
+ parked)
4526
+ [ -z "$CS_OPEN" ] || open=", open: $CS_OPEN"
4527
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\` (it was parked, waiting for an answer$open)" \
4528
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer to question <n> on the lines below it; the answer resumes it." ;;
4529
+ park_loop)
4530
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\` (it was held by the park-loop guard)" \
4531
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4532
+ running)
4533
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`; its cancelled job is still finishing" \
4534
+ "Comment \`$COMMAND_HANDLE resume\` once it has ended." ;;
4535
+ *)
4536
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`" \
4537
+ "Comment \`$COMMAND_HANDLE resume\` to continue it from its committed ledger." ;;
4538
+ esac
4539
+ }
4540
+
4259
4541
  # control_check_branch <branch> — the refusals both paths share, in order: a
4260
4542
  # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4261
4543
  # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
@@ -4302,20 +4584,28 @@ control_branch_from_pr() {
4302
4584
  control_check_branch "$head"
4303
4585
  }
4304
4586
 
4305
- # control_branch_from_issue <number> — the branch of the issue's last genuine
4306
- # start comment: by `github-actions[bot]`, its first line opening with the
4307
- # trigger's start sentence and a backticked <b>, and its last non-empty line
4308
- # exactly `forge_marker started <b>`. A marker anywhere else is never trusted.
4309
- control_branch_from_issue() {
4310
- local count i login body first last b found="" lead='Started a harness run on the branch `'
4587
+ # control_issue_branch_var <number> — ISSUE_BRANCH, the branch of the issue's
4588
+ # last genuine start comment: by `github-actions[bot]`, its first line opening
4589
+ # with the trigger's start sentence and a backticked <b>, and its last non-empty
4590
+ # line exactly `forge_marker started <b>`. A marker anywhere else is never
4591
+ # trusted. 1 when there is none, ISSUE_BRANCH_ERR then non-empty when the
4592
+ # comments could not be read. Posts nothing.
4593
+ ISSUE_BRANCH=""
4594
+ ISSUE_BRANCH_ERR=""
4595
+ control_issue_branch_var() {
4596
+ local count i login body last b found="" lead='Started a harness run on the branch `'
4597
+ ISSUE_BRANCH=""
4598
+ ISSUE_BRANCH_ERR=""
4311
4599
  if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$1/comments" --jq '.[] | {login: .user.login, body: .body}'; then
4312
- control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry."
4600
+ ISSUE_BRANCH_ERR="$GH_ERR"
4601
+ return 1
4313
4602
  fi
4314
4603
  count=$(printf '%s' "$GH_OUT" | jq -s 'length' 2>/dev/null) || count=""
4315
4604
  case "$count" in
4316
4605
  ''|*[!0-9]*)
4317
4606
  GH_ERR="its comment list is not the expected JSON"
4318
- control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry." ;;
4607
+ ISSUE_BRANCH_ERR="$GH_ERR"
4608
+ return 1 ;;
4319
4609
  esac
4320
4610
  for ((i = 0; i < count; i++)); do
4321
4611
  login=$(printf '%s' "$GH_OUT" | jq -s -r --argjson i "$i" '.[$i].login // ""' 2>/dev/null) || continue
@@ -4338,17 +4628,27 @@ control_branch_from_issue() {
4338
4628
  [ "$last" = "$(forge_marker started "$b")" ] || continue
4339
4629
  found="$b"
4340
4630
  done
4341
- if [ -z "$found" ]; then
4631
+ [ -n "$found" ] || return 1
4632
+ ISSUE_BRANCH="$found"
4633
+ }
4634
+
4635
+ # control_branch_from_issue <number> — control_issue_branch_var, its failures
4636
+ # refused, then control_check_branch's refusals.
4637
+ control_branch_from_issue() {
4638
+ if ! control_issue_branch_var "$1"; then
4639
+ if [ -n "$ISSUE_BRANCH_ERR" ]; then
4640
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($ISSUE_BRANCH_ERR)" "Comment again to retry."
4641
+ fi
4342
4642
  control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4343
4643
  "Comment on the pull request of the run's branch instead."
4344
4644
  fi
4345
- control_check_branch "$found"
4645
+ control_check_branch "$ISSUE_BRANCH"
4346
4646
  }
4347
4647
 
4348
4648
  # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
4349
4649
  control_verb_handled() {
4350
4650
  case "$1" in
4351
- answer|pause|stop|resume|clear) return 0 ;;
4651
+ answer|pause|stop|resume|clear|status) return 0 ;;
4352
4652
  esac
4353
4653
  return 1
4354
4654
  }
@@ -4365,7 +4665,7 @@ control_resume_dispatch() {
4365
4665
  route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4366
4666
  route=${route#or from GitHub: }
4367
4667
  [ "$#" -eq 0 ] || clear=", with park_loop_clear true as well"
4368
- control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`$CS_STATE\`${CS_REASON:+, \`$CS_REASON\`}) records no engine, and the harness does not guess one" \
4668
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`$CS_WORD\`${CS_REASON:+, \`$CS_REASON\`}) records no engine, and the harness does not guess one" \
4369
4669
  "Resume it with the **Run workflow** form instead: $route$clear."
4370
4670
  fi
4371
4671
  out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
@@ -4390,11 +4690,15 @@ control_resume() {
4390
4690
  local open
4391
4691
  control_state_var "$CONTROL_BRANCH" \
4392
4692
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4693
+ control_state_word_var
4393
4694
  case "$CS_STATE" in
4394
4695
  paused)
4395
4696
  # Every pause reason, `expired` and `killed` included, resumes from the
4396
4697
  # committed ledger, as the local route does.
4397
4698
  control_resume_dispatch "Resume requested by @$CONTROL_ACTOR: \`$CONTROL_BRANCH\` continues from its committed ledger." ;;
4699
+ esac
4700
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4701
+ case "$CS_STATE" in
4398
4702
  park_loop)
4399
4703
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard" \
4400
4704
  "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
@@ -4406,20 +4710,24 @@ control_resume() {
4406
4710
  running)
4407
4711
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is already \`running\`" "Nothing needs resuming." ;;
4408
4712
  *)
4409
- control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4713
+ control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4410
4714
  "A finished run continues by a review requesting changes on its pull request, or by applying the trigger label to its issue again." ;;
4411
4715
  esac
4412
4716
  }
4413
4717
 
4414
4718
  control_clear() {
4719
+ local subject="the run"
4415
4720
  control_state_var "$CONTROL_BRANCH" \
4416
4721
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4722
+ control_state_word_var
4417
4723
  if [ "$CS_STATE" != park_loop ]; then
4418
- control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4724
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4725
+ control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4419
4726
  "Only a run held by the park-loop guard is cleared."
4420
4727
  fi
4728
+ [ "$CS_STOPPED" != 1 ] || subject="the stopped run"
4421
4729
  # On GitHub, typing `clear` is the confirmation `branch-resume` asks for.
4422
- control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; the run resumes from its committed ledger." \
4730
+ control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; $subject resumes from its committed ledger." \
4423
4731
  --park-loop-clear
4424
4732
  }
4425
4733
 
@@ -4427,8 +4735,11 @@ control_pause() {
4427
4735
  local out
4428
4736
  control_state_var "$CONTROL_BRANCH" \
4429
4737
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4738
+ control_state_word_var
4739
+ # A stopped `running` run is a cancel still finishing: nothing to pause.
4740
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4430
4741
  if [ "$CS_STATE" != running ]; then
4431
- control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_STATE\`" \
4742
+ control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`" \
4432
4743
  "Nothing needs pausing."
4433
4744
  fi
4434
4745
  out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
@@ -4463,12 +4774,78 @@ control_stop() {
4463
4774
  esac
4464
4775
  }
4465
4776
 
4777
+ # control_ledger_next_var — LEDGER_NEXT, the first `- [ ] ` line of
4778
+ # CONTROL_BRANCH's flow-progress ledger on origin's tip without that prefix,
4779
+ # and LEDGER_SECTION, the last `## ` heading above it; both empty when every
4780
+ # entry is ticked. 1 when the ledger cannot be read.
4781
+ LEDGER_NEXT=""; LEDGER_SECTION=""
4782
+ control_ledger_next_var() {
4783
+ local state_rel text line section=""
4784
+ LEDGER_NEXT=""; LEDGER_SECTION=""
4785
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
4786
+ [ -n "$state_rel" ] || return 1
4787
+ text=$(git -C "$root" show "refs/remotes/origin/$CONTROL_BRANCH:${state_rel%/}/flow_progress/${CONTROL_BRANCH}_progress.md" 2>/dev/null) \
4788
+ || return 1
4789
+ while IFS= read -r line; do
4790
+ line=${line%$'\r'}
4791
+ case "$line" in
4792
+ '## '*) section=${line#'## '} ;;
4793
+ '- [ ] '*)
4794
+ LEDGER_NEXT=${line#'- [ ] '}
4795
+ LEDGER_SECTION="$section"
4796
+ return 0 ;;
4797
+ esac
4798
+ done <<<"$text"
4799
+ return 0
4800
+ }
4801
+
4802
+ # control_status — the read-only reply: the state, the next ledger entry, the
4803
+ # open questions and the latest run. Sets no label and dispatches nothing.
4804
+ control_status() {
4805
+ local text v questions="" para=$'\n\n'
4806
+ control_state_var "$CONTROL_BRANCH" \
4807
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4808
+ control_state_word_var
4809
+ if [ "$CS_STATE" = none ]; then
4810
+ text="@$CONTROL_ACTOR: no harness run is listed for \`$CONTROL_BRANCH\`."
4811
+ else
4812
+ text="@$CONTROL_ACTOR: \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`${CS_REASON:+ (\`$CS_REASON\`)}"
4813
+ if [ "$CS_STOPPED" = 1 ]; then
4814
+ case "$CS_STATE" in
4815
+ parked) text="$text; it was parked, waiting for an answer" ;;
4816
+ park_loop) text="$text; it was held by the park-loop guard" ;;
4817
+ running) text="$text; its cancelled job is still finishing" ;;
4818
+ esac
4819
+ fi
4820
+ # An expired bundle is reported as expired, never as absent.
4821
+ [ "$CS_REASON" != expired ] || text="$text: ${CS_DETAIL:-the state bundle of the run has expired}"
4822
+ text="$text."
4823
+ fi
4824
+ if control_ledger_next_var; then
4825
+ if [ -n "$LEDGER_NEXT" ]; then
4826
+ text="$text${para}Next in the flow-progress ledger: $LEDGER_NEXT${LEDGER_SECTION:+ (under \`$LEDGER_SECTION\`)}"
4827
+ else
4828
+ text="$text${para}Every entry of the flow-progress ledger is ticked."
4829
+ fi
4830
+ else
4831
+ text="$text${para}The flow-progress ledger could not be read, so its next entry is left out."
4832
+ fi
4833
+ if [ -n "$CS_OPEN" ]; then
4834
+ for v in $CS_OPEN; do
4835
+ questions="$questions${questions:+, }$v (\`$COMMAND_HANDLE answer $v\`)"
4836
+ done
4837
+ text="$text${para}Open questions: $questions, each answered by its own comment with the answer on the lines below the command."
4838
+ fi
4839
+ [ -z "$CS_URL" ] || text="$text${para}Latest run: $CS_URL"
4840
+ control_reply "$EXIT_OK" "$text"
4841
+ }
4842
+
4466
4843
  # control_answer — one answer, one `resume: answer` dispatch with one entry. A
4467
4844
  # park left partly answered is safe: run_job stops an `answer` job whose park
4468
4845
  # is not fully answered before any session, and its bundle then carries the
4469
4846
  # `answer_<n>.md` restore wrote, so the next answer's job finds the set complete.
4470
4847
  control_answer() {
4471
- local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK"
4848
+ local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK" named
4472
4849
  first=${CONTROL_ARGS%%[$' \t']*}
4473
4850
  if [[ "$first" =~ ^[1-9][0-9]*$ ]]; then
4474
4851
  n="$first"
@@ -4494,6 +4871,14 @@ control_answer() {
4494
4871
 
4495
4872
  control_state_var "$CONTROL_BRANCH" \
4496
4873
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4874
+ control_state_word_var
4875
+ # On a stopped parked run the answer dispatch is its resume; any other
4876
+ # stopped state is refused naming `stopped`.
4877
+ if [ "$CS_STOPPED" = 1 ] && [ "$CS_STATE" != parked ]; then
4878
+ control_stopped_refuse
4879
+ fi
4880
+ named="\`$CS_WORD\`"
4881
+ [ "$CS_STOPPED" != 1 ] || named="$named (it was parked)"
4497
4882
  case "$CS_STATE:$CS_REASON" in
4498
4883
  park_loop:*)
4499
4884
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard, not waiting for an answer" \
@@ -4510,11 +4895,11 @@ control_answer() {
4510
4895
  ;;
4511
4896
  parked:*) ;;
4512
4897
  *)
4513
- control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4898
+ control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4514
4899
  "Nothing is waiting for an answer." ;;
4515
4900
  esac
4516
4901
  if [ -z "$CS_OPEN" ]; then
4517
- control_refuse "$EXIT_REFUSED" "only a parked run with an open question can be answered, and the run on \`$CONTROL_BRANCH\` is \`parked\` with none open" \
4902
+ control_refuse "$EXIT_REFUSED" "only a parked run with an open question can be answered, and the run on \`$CONTROL_BRANCH\` is $named with none open" \
4518
4903
  "Nothing is waiting for an answer."
4519
4904
  fi
4520
4905
  for v in $CS_OPEN; do
@@ -4540,7 +4925,7 @@ control_answer() {
4540
4925
  route=${route#or from GitHub: }
4541
4926
  form="resume answer and answers \`{\"$n\": \"<the answer>\"}\`"
4542
4927
  route=${route/and resume pause/$form}
4543
- control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`parked\`) records no engine, and the harness does not guess one" \
4928
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` ($named) records no engine, and the harness does not guess one" \
4544
4929
  "Answer it with the **Run workflow** form instead: $route."
4545
4930
  fi
4546
4931
 
@@ -4579,10 +4964,18 @@ control_answer() {
4579
4964
  done
4580
4965
  if [ -n "$rest" ]; then
4581
4966
  # The label stays `parked` until the last answer: only that job resumes.
4967
+ if [ "$CS_STOPPED" = 1 ]; then
4968
+ control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; the stopped run on \`$CONTROL_BRANCH\` resumes once question(s) $rest are answered: $cmds."
4969
+ fi
4582
4970
  control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; question(s) $rest still need an answer: $cmds."
4583
4971
  fi
4584
- control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
4585
- || status="$EXIT_GH"
4972
+ if [ "$CS_STOPPED" = 1 ]; then
4973
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so the stopped run on \`$CONTROL_BRANCH\` resumes." \
4974
+ || status="$EXIT_GH"
4975
+ else
4976
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
4977
+ || status="$EXIT_GH"
4978
+ fi
4586
4979
  forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4587
4980
  forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4588
4981
  [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
@@ -4884,18 +5277,33 @@ control_review_in_flight() {
4884
5277
  state="\`$BS_STATE\`"
4885
5278
  [ -z "$BS_REASON" ] || state="$state (\`$BS_REASON\`)"
4886
5279
  fi
4887
- case "$BS_STATE" in
4888
- parked)
4889
- way=" The run waits for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it." ;;
4890
- park_loop)
4891
- way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
4892
- paused)
4893
- if [ "$BS_REASON" = usage ]; then
4894
- way=" The run resumes by itself once the usage limit resets."
4895
- else
4896
- way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
4897
- fi ;;
4898
- esac
5280
+ if control_branch_stopped "$BS_STATE"; then
5281
+ # A stopped run continues only by the command its underlying state takes.
5282
+ state="\`stopped\`"
5283
+ case "$BS_STATE" in
5284
+ parked)
5285
+ way=" The run was stopped while waiting for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it; the answer resumes it." ;;
5286
+ park_loop)
5287
+ way=" The run was stopped while held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
5288
+ running)
5289
+ way=" Its cancelled job is still finishing: comment \`$COMMAND_HANDLE resume\` once it has ended." ;;
5290
+ *)
5291
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger." ;;
5292
+ esac
5293
+ else
5294
+ case "$BS_STATE" in
5295
+ parked)
5296
+ way=" The run waits for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it." ;;
5297
+ park_loop)
5298
+ way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
5299
+ paused)
5300
+ if [ "$BS_REASON" = usage ]; then
5301
+ way=" The run resumes by itself once the usage limit resets."
5302
+ else
5303
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
5304
+ fi ;;
5305
+ esac
5306
+ fi
4899
5307
  branch="$CONTROL_BRANCH"
4900
5308
  if round_markers_read "$REVIEW_ID" && [ -n "$RC_EVENT_ROUND" ]; then
4901
5309
  control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND, which is $state on \`$CONTROL_BRANCH\`."
@@ -5054,14 +5462,193 @@ control_comment_intake() {
5054
5462
  return 0
5055
5463
  }
5056
5464
 
5465
+ # The close: CLOSE_KIND (`issue`, `pr_closed`, `pr_merged`, `deleted`) and
5466
+ # CLOSE_REF, the event's own branch (a pull request's head, a deleted ref;
5467
+ # empty for an issue). CONTROL_NUMBER is the item, empty for a deletion.
5468
+ CLOSE_KIND=""
5469
+ CLOSE_REF=""
5470
+
5471
+ control_event_unreadable() {
5472
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
5473
+ exit "$EXIT_USAGE"
5474
+ }
5475
+
5476
+ # control_issues_intake — read an `issues` event; 1 when ignored, after one line.
5477
+ control_issues_intake() {
5478
+ local action
5479
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5480
+ && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5481
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5482
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5483
+ if [ "$action" != closed ]; then
5484
+ echo "remote-run.sh: control: ignored, an issue $action, not closed"
5485
+ return 1
5486
+ fi
5487
+ CLOSE_KIND=issue
5488
+ CONTROL_VERB=close
5489
+ return 0
5490
+ }
5491
+
5492
+ # control_pull_request_intake — read a `pull_request` event; 1 when ignored,
5493
+ # after one line.
5494
+ control_pull_request_intake() {
5495
+ local action merged head_repo
5496
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5497
+ && event_field '.pull_request.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5498
+ && event_field '.pull_request.merged // false' && merged="$EVENT_VALUE" \
5499
+ && event_field '.pull_request.head.ref // ""' && CLOSE_REF="$EVENT_VALUE" \
5500
+ && event_field '.pull_request.head.repo.full_name // ""' && head_repo="$EVENT_VALUE" \
5501
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5502
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5503
+ if [ "$action" != closed ]; then
5504
+ echo "remote-run.sh: control: ignored, a pull request $action, not closed"
5505
+ return 1
5506
+ fi
5507
+ if [ -z "$head_repo" ] || [ "$head_repo" != "${GITHUB_REPOSITORY-}" ]; then
5508
+ echo "remote-run.sh: control: ignored, a pull request from ${head_repo:-an unnamed repository}, not ${GITHUB_REPOSITORY:-this repository}"
5509
+ return 1
5510
+ fi
5511
+ CLOSE_KIND=pr_closed
5512
+ [ "$merged" != true ] || CLOSE_KIND=pr_merged
5513
+ CONTROL_VERB=close
5514
+ return 0
5515
+ }
5516
+
5517
+ # control_delete_intake — read a `delete` event; 1 when ignored, after one line.
5518
+ control_delete_intake() {
5519
+ local ref_type
5520
+ { event_field '.ref // ""' && CLOSE_REF="$EVENT_VALUE" \
5521
+ && event_field '.ref_type // ""' && ref_type="$EVENT_VALUE" \
5522
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5523
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5524
+ if [ "$ref_type" != branch ]; then
5525
+ echo "remote-run.sh: control: ignored, a deleted ${ref_type:-ref}, not a branch"
5526
+ return 1
5527
+ fi
5528
+ if ! valid_branch "$CLOSE_REF"; then
5529
+ echo "remote-run.sh: control: ignored, a deleted branch with no valid name"
5530
+ return 1
5531
+ fi
5532
+ CLOSE_KIND=deleted
5533
+ CONTROL_VERB=close
5534
+ return 0
5535
+ }
5536
+
5537
+ # control_close_ignore <reason> — the one line every close refusal is, and exit
5538
+ # 0: a close is never replied to, labelled or dispatched for.
5539
+ control_close_ignore() {
5540
+ echo "remote-run.sh: control: close ignored: $1"
5541
+ exit "$EXIT_OK"
5542
+ }
5543
+
5544
+ # control_close — THE CLOSE (the header): the quiet gates in order, the state
5545
+ # rule, then the `stop` child with the event's note.
5546
+ control_close() {
5547
+ local status=0 protected=0 b="" what note out
5548
+ local stop_flags=()
5549
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5550
+ control_close_ignore "the repository variable HARNESS_REMOTE_STOP is set"
5551
+ fi
5552
+ forge_on || control_close_ignore "the default branch's harness.config.json does not turn run control on"
5553
+ if [ "$CLOSE_KIND" = deleted ]; then
5554
+ # Deleting a branch already needs write access; only a bot is screened.
5555
+ if [ "$CONTROL_SENDER_TYPE" = Bot ] && ! trigger_bot_listed "$CONTROL_ACTOR"; then
5556
+ control_close_ignore "@$CONTROL_ACTOR is a bot not listed in HARNESS_TRIGGER_ALLOWED_BOTS"
5557
+ fi
5558
+ else
5559
+ case "$CONTROL_NUMBER" in
5560
+ ''|*[!0-9]*|0*)
5561
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5562
+ exit "$EXIT_USAGE" ;;
5563
+ esac
5564
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5565
+ case "$status" in
5566
+ 0) ;;
5567
+ 4)
5568
+ echo "::error::remote-run.sh: control: the close by @$CONTROL_ACTOR was not acted on: ${AUTH_WHY%.}"
5569
+ exit "$EXIT_GH" ;;
5570
+ *) control_close_ignore "@$CONTROL_ACTOR is not authorised: ${AUTH_WHY%.}" ;;
5571
+ esac
5572
+ fi
5573
+
5574
+ case "$CLOSE_KIND" in
5575
+ issue)
5576
+ if ! forge_repo_var; then
5577
+ echo "::error::remote-run.sh: control: the repository's name could not be read ($GH_ERR)"
5578
+ exit "$EXIT_GH"
5579
+ fi
5580
+ if ! control_issue_branch_var "$CONTROL_NUMBER"; then
5581
+ if [ -n "$ISSUE_BRANCH_ERR" ]; then
5582
+ echo "::error::remote-run.sh: control: the comments of issue #$CONTROL_NUMBER could not be read ($ISSUE_BRANCH_ERR)"
5583
+ exit "$EXIT_GH"
5584
+ fi
5585
+ control_close_ignore "no harness run was started from issue #$CONTROL_NUMBER"
5586
+ fi
5587
+ b="$ISSUE_BRANCH" ;;
5588
+ *) b="$CLOSE_REF" ;;
5589
+ esac
5590
+ if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
5591
+ control_close_ignore "\`$b\` is not a valid branch name"
5592
+ fi
5593
+ hr_branch_is_protected "$root" "$b" || protected=$?
5594
+ case "$protected" in
5595
+ 1) ;;
5596
+ 0) control_close_ignore "\`$b\` is a protected branch" ;;
5597
+ *) control_close_ignore "whether \`$b\` is protected could not be judged from harness.config.json" ;;
5598
+ esac
5599
+ CONTROL_BRANCH="$b"
5600
+ if [ "$CLOSE_KIND" = pr_closed ] || [ "$CLOSE_KIND" = pr_merged ]; then
5601
+ # GitHub closes a pull request whose head is deleted; the `delete` event's
5602
+ # own job stops that run from the default branch, so this one stays quiet.
5603
+ remote_branch_exists "$b"
5604
+ case $? in
5605
+ 1) control_close_ignore "the branch \`$b\` of pull request #$CONTROL_NUMBER is gone from origin; the deletion's own job stops the run" ;;
5606
+ 2) echo "remote-run.sh: control: whether \`$b\` exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
5607
+ esac
5608
+ fi
5609
+
5610
+ # No forge_recognised check: a merged or deleted branch may no longer carry
5611
+ # its ledger, and a listed `harness run <b>` run is what proves a harness run.
5612
+ if ! control_state_var "$b"; then
5613
+ echo "::error::remote-run.sh: control: the state of the run on \`$b\` could not be read ($CS_ERR)"
5614
+ exit "$EXIT_GH"
5615
+ fi
5616
+ case "$CS_STATE" in
5617
+ running|parked|park_loop|paused) ;;
5618
+ *) control_close_ignore "left alone: the run on \`$b\` is \`${CS_STATE:-unknown}\`" ;;
5619
+ esac
5620
+ control_branch_stopped "$CS_STATE" && control_close_ignore "the run on \`$b\` is already stopped"
5621
+
5622
+ case "$CLOSE_KIND" in
5623
+ issue) what="closed issue #$CONTROL_NUMBER" ;;
5624
+ pr_closed) what="closed pull request #$CONTROL_NUMBER"; stop_flags=(--pr "$CONTROL_NUMBER") ;;
5625
+ pr_merged) what="merged pull request #$CONTROL_NUMBER"; stop_flags=(--pr "$CONTROL_NUMBER") ;;
5626
+ deleted) what="deleted the branch \`$b\`"; stop_flags=(--branch-gone) ;;
5627
+ esac
5628
+ note="Stopped because @$CONTROL_ACTOR $what."
5629
+ if [ "$CLOSE_KIND" != deleted ]; then
5630
+ note="$note Its workflow runs and their \`$STATE_ARTIFACT_NAME\` artifacts are kept."
5631
+ fi
5632
+ echo "remote-run.sh: control: close of $b: @$CONTROL_ACTOR $what"
5633
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
5634
+ control_child "$out" stop "$b" --actor "$CONTROL_ACTOR" --note "$note" ${stop_flags[@]+"${stop_flags[@]}"} --repo "$root"
5635
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
5636
+ if [ "$CHILD_STATUS" -ne 0 ]; then
5637
+ echo "::error::remote-run.sh: control: the stop of \`$b\` after @$CONTROL_ACTOR $what failed: $CHILD_LAST"
5638
+ exit "$EXIT_GH"
5639
+ fi
5640
+ exit "$EXIT_OK"
5641
+ }
5642
+
5057
5643
  verb_control() {
5058
5644
  local LC_ALL=C
5059
- local review=0 forge="" target="" status verbs="" v
5645
+ local review=0 close=0 forge="" target="" status verbs="" v
5060
5646
  case "${GITHUB_EVENT_NAME-}" in
5061
5647
  issue_comment) ;;
5062
5648
  pull_request_review) review=1 ;;
5649
+ issues|pull_request|delete) close=1 ;;
5063
5650
  *)
5064
- echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment or pull_request_review, not '${GITHUB_EVENT_NAME-}'" >&2
5651
+ echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment, pull_request_review, issues, pull_request or delete, not '${GITHUB_EVENT_NAME-}'" >&2
5065
5652
  exit "$EXIT_USAGE" ;;
5066
5653
  esac
5067
5654
  if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
@@ -5070,18 +5657,22 @@ verb_control() {
5070
5657
  fi
5071
5658
  hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
5072
5659
 
5073
- if [ "$review" -eq 1 ]; then
5074
- control_review_intake || return 0
5075
- else
5076
- control_comment_intake || return 0
5077
- fi
5078
-
5079
- case "$CONTROL_NUMBER" in
5080
- ''|*[!0-9]*|0*)
5081
- echo "remote-run.sh: control: the event carries no issue number" >&2
5082
- exit "$EXIT_USAGE" ;;
5660
+ case "$GITHUB_EVENT_NAME" in
5661
+ pull_request_review) control_review_intake || return 0 ;;
5662
+ issue_comment) control_comment_intake || return 0 ;;
5663
+ issues) control_issues_intake || return 0 ;;
5664
+ pull_request) control_pull_request_intake || return 0 ;;
5665
+ delete) control_delete_intake || return 0 ;;
5083
5666
  esac
5084
5667
 
5668
+ if [ "$close" -eq 0 ]; then
5669
+ case "$CONTROL_NUMBER" in
5670
+ ''|*[!0-9]*|0*)
5671
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5672
+ exit "$EXIT_USAGE" ;;
5673
+ esac
5674
+ fi
5675
+
5085
5676
  control_tmp="${RUNNER_TEMP-}"
5086
5677
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5087
5678
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
@@ -5089,6 +5680,9 @@ verb_control() {
5089
5680
  fi
5090
5681
  trap control_cleanup EXIT
5091
5682
 
5683
+ # Decided by the event name, never CONTROL_VERB: a comment can name `close`.
5684
+ [ "$close" -eq 0 ] || control_close
5685
+
5092
5686
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5093
5687
  control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
5094
5688
  "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
@@ -5133,6 +5727,7 @@ verb_control() {
5133
5727
  stop) control_stop ;;
5134
5728
  resume) control_resume ;;
5135
5729
  clear) control_clear ;;
5730
+ status) control_status ;;
5136
5731
  review) control_review ;;
5137
5732
  esac
5138
5733
  }