autonomous-sdlc-harness 0.6.0 → 0.6.2

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>]
@@ -190,13 +191,18 @@
190
191
  # a refusal can still be commented. It reads, only from the environment:
191
192
  # GITHUB_EVENT_NAME, GITHUB_EVENT_PATH the event; each field is read by `jq`
192
193
  # into a variable and is only ever an argument or file
193
- # bytes, never shell source
194
+ # bytes, never shell source; `.repository.owner.login`
195
+ # and `.repository.owner.type` only when
196
+ # `HARNESS_RUN_ACTORS` is unset
194
197
  # GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
195
198
  # the URLs its comments name
196
199
  # HARNESS_REMOTE_STOP non-empty: every start is refused
197
200
  # HARNESS_TRIGGER_LABEL the trigger label; when empty, `DEFAULT_TRIGGER_LABEL`
198
201
  # or `LEGACY_TRIGGER_LABEL`, per the paragraph below
199
202
  # HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
203
+ # HARNESS_RUN_ACTORS comma-separated people allowed to act on a run, `*`
204
+ # for every writer; unset admits a `User` owner alone
205
+ # (`run_actor_listed`)
200
206
  # HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
201
207
  # A test seam
202
208
  # RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
@@ -225,7 +231,13 @@
225
231
  # 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
226
232
  # `write` — `maintain` reads as `write` and `triage` as `read` there; a
227
233
  # failed call is "could not confirm write access", never a pass
228
- # Refusals 4 to 6 are `authorise_actor`, the one actor check `control` reuses.
234
+ # 7. a writer `HARNESS_RUN_ACTORS` does not admit — after refusal 6, so a
235
+ # non-writer is still told about write access
236
+ # Refusals 4 to 7 are `authorise_actor`, the one actor check `control` reuses.
237
+ # Before them, after refusal 2, a re-run (GITHUB_RUN_ATTEMPT above 1) whose
238
+ # GITHUB_TRIGGERING_ACTOR is not `github-actions[bot]` and is not admitted by
239
+ # `HARNESS_RUN_ACTORS` is refused (`rerun_actor_listed`), on both event kinds:
240
+ # a re-run replays the event, so its sender is whoever first acted.
229
241
  # Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
230
242
  # branch with `hr_derive_branch <title> issue_<number>`, passing `gh` so a name
231
243
  # with run-workflow history counts as taken (2 or 3 refused), writes
@@ -251,7 +263,10 @@
251
263
  # and exit 0 with no `gh` call; an empty or missing `title` is refused. It has
252
264
  # no labeller: GitHub sends one only for a fine-grained token with Contents
253
265
  # write or a classic token with `repo`, so the token holder is the authority,
254
- # and refusals 3 to 6 do not apply. The fallback name is `task_<GITHUB_RUN_ID>`
266
+ # and refusals 3 to 6 do not apply. After refusal 2 a `User` sender
267
+ # (`.sender.login`, `.sender.type`) that `HARNESS_RUN_ACTORS` does not admit is
268
+ # refused with no permission call, and so is a sender with no type unless the
269
+ # list is `*`, failing closed; any other sender type passes. The fallback name is `task_<GITHUB_RUN_ID>`
255
270
  # and the snapshot's provenance sentence names the event type, `source` when
256
271
  # set, and the time. There is no issue, so no comment and no label: every
257
272
  # outcome is printed to stdout and appended as a Markdown block to
@@ -273,26 +288,33 @@
273
288
  # whose head is <branch> when origin's <branch> carries
274
289
  # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
275
290
  # 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
291
+ # task prompt, else nowhere (`stop`'s `--pr` and `--branch-gone` change this
292
+ # for its own report: its paragraph). The label `STATE_LABEL_PREFIX<state>` replaces any
277
293
  # other state label on that issue and that pull request, each when known; the
278
294
  # label is a view, and the run list stays the authority. The state map:
279
295
  # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
280
296
  # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
281
- # `failed` posts nothing when
282
- # `remote_branch_stopped` finds the branch stopped, so a cancelled job never
283
- # overwrites `stopped`. `completed` (deliver's) and `launched` (the trigger's
297
+ # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round` and
298
+ # `failed` — posts nothing and sets no label when `remote_branch_stopped`,
299
+ # asked afresh, finds the branch stopped, so a job a stop overtook never
300
+ # overwrites `stopped`; a failed listing reports anyway, and `stopped` is never
301
+ # withheld. `completed` (deliver's) and `launched` (the trigger's
284
302
  # own comment) are one line each, as is any other event. The comment names the
285
303
  # next GitHub action — never a slash command — then <note> byte for byte, then
286
304
  # this run's URL when `GITHUB_RUN_ID` is set, then the marker line
287
305
  # `<!-- sdlc-harness event=<event> branch=<branch> -->`. The pause reason and
288
306
  # reset come from the registry record, read only when the registry file exists.
289
307
  # `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
308
+ # over `$root`'s state directory, ascending — carrying `question_<n>.md` less
309
+ # every line naming its own `answer_<n>.md` (another index's line stays), cut
310
+ # at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
311
+ # the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, the
312
+ # comment's one answer instruction: a comment whose first line is
313
+ # `COMMAND_HANDLE answer <n>` (`<n>` optional when one question is open) and
314
+ # whose following lines are the answer; then a fenced copy block of that form,
315
+ # `COMMAND_HANDLE answer <n>` over `<your answer>`; the marker adds
316
+ # `question=<n>`. With no question open it posts nothing, sets no label and
317
+ # prints one `::error::` line naming the branch. The label
296
318
  # is set once per target, not per question. On a public repository a question
297
319
  # comment and its answer are public, as the artifact already is
298
320
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
@@ -356,8 +378,9 @@
356
378
  # and outside the sending-verb gate: it gates itself after reading the event,
357
379
  # so a refusal can still be replied to. The workflow's `if:` only saves a
358
380
  # 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.
381
+ # `issue_comment`, `pull_request_review` (THE REVIEW, below), and `issues`,
382
+ # `pull_request` and `delete` (THE CLOSE, below); any other name, or an event
383
+ # file it cannot read, exits 1.
361
384
  # It reads `.action`, `.comment.body`, `.issue.number`,
362
385
  # `.issue.pull_request.url`, `.sender.login` and `.sender.type`, each by `jq`
363
386
  # into a variable (data, never shell source), plus `trigger`'s environment.
@@ -372,7 +395,10 @@
372
395
  # 1. `HARNESS_REMOTE_STOP` is set
373
396
  # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
374
397
  # GitHub nothing about the commenter
375
- # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run
398
+ # then a re-run whose GITHUB_TRIGGERING_ACTOR `rerun_actor_listed`
399
+ # refuses, before the event's own actor is checked
400
+ # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run — a
401
+ # writer the `HARNESS_RUN_ACTORS` allow-list admits, or a listed bot
376
402
  # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
377
403
  # yet: the reply lists every command and names `docs/github-run-control.md`
378
404
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
@@ -429,7 +455,29 @@
429
455
  # way on. On 0 with no other question open, a reply that the run resumes and
430
456
  # `running` on its issue and pull request; with others open, a reply naming
431
457
  # 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.
458
+ # public on a public repository, as the question already is. A STOPPED RUN IS
459
+ # NAMED `stopped`: a stop leaves the bundle as it was, so after `pause`,
460
+ # `resume`, `answer` and `clear` read the state, `control_state_word_var` asks
461
+ # `remote_branch_stopped` (one extra run listing) whether a `running`,
462
+ # `parked`, `park_loop` or `paused` run was stopped, and every reply names it
463
+ # `stopped`, with the state underneath where the way on depends on it. The
464
+ # states each verb accepts are unchanged, except that `pause` refuses a
465
+ # stopped `running` run, whose cancelled job is still finishing: `resume`
466
+ # still resumes a stopped `paused` run, and on a stopped `parked` run the
467
+ # answer, on a stopped `park_loop` run the clear, is its resume. `status` is
468
+ # READ-ONLY and accepted in every state, `none` and a run in flight included:
469
+ # it reads the state (`control_state_var`, `control_state_word_var`) and the
470
+ # first `- [ ] ` entry of the flow-progress ledger on origin's tip with the
471
+ # `## ` heading above it, and replies once — the state by `CS_WORD` with its
472
+ # pause reason, the state underneath a stopped one, an expired bundle's detail,
473
+ # the next ledger entry (or, on a fully ticked ledger, that a user-review round
474
+ # has started and its ledger is not written yet, when origin's tip carries the
475
+ # round's `chore: add user review for <branch>` commit after the ledger's last
476
+ # change; else, for a `running` run, that it is still finishing or starting a
477
+ # stage, never that every entry is ticked; else that every entry is ticked; or
478
+ # that the ledger could not be read), each open question with its `answer <n>` form, and the
479
+ # latest run's URL. It sets no label and runs no child verb but `fetch`; a
480
+ # failed state read is a refusal and exit 3.
433
481
  # THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
434
482
  # `.review.body`, `.review.id`, `.review.html_url`, `.review.submitted_at`,
435
483
  # `.pull_request.number`, `.pull_request.head.ref`,
@@ -451,7 +499,10 @@
451
499
  # `your review was collected`, naming the state and saying the next round
452
500
  # starts by itself when that run finishes, plus the state's way on: `answer
453
501
  # <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
502
+ # `usage`, and for `usage` that it resumes after the reset. A run of those
503
+ # states that `remote_branch_stopped` finds stopped is named `stopped`, and its
504
+ # way on is the one `resume` names for it: `answer <n>`, `clear`, `resume`, or
505
+ # for `running` `resume` once the cancelled job has ended. The review stays on
455
506
  # the pull request, and a later round collects it. Settled: a round is CUMULATIVE,
456
507
  # built by `round_collect` in a fresh file under `RUNNER_TEMP` from ONE
457
508
  # paginated `pulls/<n>/reviews` and ONE paginated `pulls/<n>/comments` listing
@@ -468,9 +519,10 @@
468
519
  # inline comment, by any author and whatever its review's state, whose id is
469
520
  # unrecorded, whose body carries no `COMMENT_MARKER`, and whose `created_at` is
470
521
  # at or after the boundary or whose review is pending. Every distinct author
471
- # of a pending item passes `authorise_actor`; a refused author's items are
472
- # dropped with one line naming the login, `AUTH_WHY` and the count, and a
473
- # failed permission call fails the collection. A round is placed only when at
522
+ # of a pending item passes `authorise_actor`, the `HARNESS_RUN_ACTORS`
523
+ # allow-list included; a refused author's items — a writer the list refuses
524
+ # among them — are dropped with one line naming the login, `AUTH_WHY` and the
525
+ # count, and a failed permission call (status 4) fails the collection. A round is placed only when at
474
526
  # least one kept review has state `REVIEW_ROUND_STATE`; a *Comment* or
475
527
  # *Approve* review rides along in the next round one requesting changes starts.
476
528
  # The file: one `## Review by @<login>` section per pending review, oldest
@@ -505,11 +557,61 @@
505
557
  # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
506
558
  # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
507
559
  # `@<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
560
+ # THE CLOSE. Whatever the run's phase, it is stopped when its issue is closed,
561
+ # its pull request closed or merged, or its branch deleted; the routing is by
562
+ # event name, never by verb, so a comment naming `close` is not one. `issues`
563
+ # reads `.action`, `.issue.number`, `.sender.login`, `.sender.type`;
564
+ # `pull_request` reads `.action`, `.pull_request.number`,
565
+ # `.pull_request.merged`, `.pull_request.head.ref`,
566
+ # `.pull_request.head.repo.full_name` and the sender; `delete` reads `.ref`,
567
+ # `.ref_type` and the sender — each by `event_field`. Ignored, with one line
568
+ # and no `gh` call: an action other than `closed`; a pull request whose head
569
+ # repository is not `GITHUB_REPOSITORY`; a `ref_type` other than `branch`, or
570
+ # a ref `valid_branch` rejects. A CLOSE IS NEVER REPLIED TO: each gate below,
571
+ # in order, is one line and exit 0, with no comment, label or dispatch —
572
+ # 1. `HARNESS_REMOTE_STOP` is set
573
+ # 2. `forge_on` fails
574
+ # 3. the actor: on `issues` and `pull_request`, `authorise_actor` refused
575
+ # (statuses 1–3 and 5, so a writer the `HARNESS_RUN_ACTORS` allow-list
576
+ # refuses is ignored too), the line naming the login and `AUTH_WHY`; on
577
+ # `delete`, only a `Bot` sender `trigger_bot_listed` does not list, and
578
+ # never the allow-list: deleting a branch already needs write access, the
579
+ # run's branch is gone, and a stop spends no credential
580
+ # 4. no branch: an issue's from `control_issue_branch_var` (no genuine start
581
+ # comment), a pull request's head ref, a deletion's `.ref`; then not a
582
+ # valid branch name
583
+ # 5. `hr_branch_is_protected` does not answer 1
584
+ # 6. on `pull_request`, the head branch absent on origin (`remote_branch_exists`
585
+ # answers 1): GitHub closed the pull request because the branch was
586
+ # deleted, and the `delete` event's job stops the run; an `ls-remote` that
587
+ # cannot answer is one line and proceeds
588
+ # There is no ledger-at-tip check (`forge_recognised`): a merged or deleted
589
+ # branch may no longer carry one, and a listed `harness run <b>` run is the
590
+ # proof. The state is read by `control_state_var`: only `running`, `parked`,
591
+ # `park_loop` and `paused` are acted on; `completed`, `failed` and `none` are
592
+ # `left alone: the run on <b> is <state>`, and one `remote_branch_stopped`
593
+ # already finds stopped is `already stopped` — each one line and exit 0. Then
594
+ # `stop <b> --actor <login> --note <note>` runs as a child (`control_child`),
595
+ # the note `Stopped because @<login> closed issue #<n>.`, `… closed pull
596
+ # request #<n>.` or `… merged pull request #<n>.` (both with `--pr <n>`), or
597
+ # `… deleted the branch `<b>`.` (with `--branch-gone`). Every note but the
598
+ # deletion's adds that the workflow runs and their `harness-state` artifacts
599
+ # are kept; a closed pull request's `stopped` comment says to reopen it, or to
600
+ # use the issue, before commenting `@sdlc-harness resume`. The runs and
601
+ # artifacts are kept. Reopening the issue or the pull
602
+ # request does nothing. A `pull_request` close job runs the pull request's
603
+ # merge-commit copy of the workflow, as a review job does
604
+ # (docs/github-integration-research.md -> C2); the script stays the default
605
+ # branch's.
606
+ # 0 handled (replied), or ignored; a close stopped, or ignored (one line)
607
+ # 1 not an event named above, or the event could not be read
510
608
  # 2 refused (replied)
511
609
  # 3 a `gh` step failed: the reply could not be posted (an `::error::`
512
- # line), or the action failed and was replied to
610
+ # line), or the action failed and was replied to; for a close, an
611
+ # `::error::` line and no reply, because the item is closed: the stop
612
+ # child failed (naming `CHILD_LAST`), or the repository name, the
613
+ # closer's permission, the issue's comments or the run's state could
614
+ # not be read
513
615
  # 4 a review's round could not be placed; nothing was dispatched (replied)
514
616
  #
515
617
  # `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
@@ -606,12 +708,17 @@
606
708
  # run URL, no dispatch
607
709
  # decision continue refused, in this order: `HARNESS_REMOTE_STOP` set (one
608
710
  # `paused`); the branch stopped (one log line, no
609
- # notification — the user asked for it); chain unreadable
711
+ # notification — the user asked for it); the branch absent
712
+ # on origin (one log line, no notification; an
713
+ # `ls-remote` that cannot answer is one line and proceeds,
714
+ # since the dispatch then fails loudly); chain unreadable
610
715
  # or `chain + 1` over `HARNESS_MAX_CHAIN` (one `failed`);
611
716
  # otherwise re-dispatched. A dispatch that fails is one
612
717
  # `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
718
+ # decision wait-poller the branch stopped, or absent on origin: one log
719
+ # line (an `ls-remote` that cannot answer is one line and
720
+ # proceeds). Otherwise `gh workflow enable
721
+ # WORKFLOW_RESUME_FILE`; a failed enable
615
722
  # (the job token's enable permission is unverified) is one
616
723
  # `paused` saying auto-resume is unavailable
617
724
  # decision stop nothing: job mode has already notified
@@ -647,8 +754,10 @@
647
754
  # says `status: paused` / `pause_reason: usage` it is waiting, because the job
648
755
  # uploads before its `continue` step enables the poller; an artifact lookup or
649
756
  # 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
757
+ # skipped, not waiting: a stopped branch, a branch absent on origin (its state
758
+ # entry dropped, so a later branch of the name starts clean; an `ls-remote`
759
+ # that cannot answer is one line and proceeds), a bundle that cannot be
760
+ # downloaded (one line), and anything but `status: paused` / `pause_reason: usage` with an
652
761
  # integer `usage_resume_at`, and a run whose state entry says `notified`. Due
653
762
  # (reset passed): re-dispatched under the same chain limit — a refusal is one
654
763
  # `failed` and not waiting; a success drops the branch's state entry. A
@@ -777,11 +886,24 @@
777
886
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
778
887
  # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
779
888
  # 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.
889
+ # reported as `report stopped` with the note `--note <text>` when given, else
890
+ # `Stopped by @<actor>.` under `--actor` (a login, the trigger's shape plus an
891
+ # optional `[bot]`; anything else is a usage error), else one naming a local
892
+ # stop; a partial stop reports nothing. Under `--pr <n>` that report's comment
893
+ # and label go to pull request <n> whatever its state — the open-PR lookup
894
+ # cannot find a closed one — and the issue is still labelled. The cancelled
895
+ # job's own `failed` is then posted nowhere, because `report` finds the branch
896
+ # stopped. `--branch-gone` is for a branch GitHub no longer has: (1)'s marker
897
+ # is dispatched with `--ref` set to GitHub's own default branch (read as `warm`
898
+ # reads it; a failed read exits 3 before anything is sent) and still carries
899
+ # `-f branch=<branch>`, so its `harness stop <branch>` title is unchanged;
900
+ # (2)'s listing also reads `headSha,createdAt`; (4) reads the issue from the
901
+ # task prompt at the newest `harness run <branch>` run's `headSha` through the
902
+ # contents API rather than from `origin/<branch>` (a failed read is one line
903
+ # and no issue), and its text says the branch was deleted, so the run cannot
904
+ # be resumed, and that its workflow runs and artifacts are kept. That read
905
+ # rests on GitHub serving a commit no branch points at, which is unverified
906
+ # (`docs/github-run-control.md` -> `## 8. What is not verified here`).
785
907
  #
786
908
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
787
909
  # --json defaultBranchRef`), which may differ from the configured
@@ -840,6 +962,7 @@
840
962
  # DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
841
963
  # LEGACY_TRIGGER_LABEL mirrors LEGACY_TRIGGER_LABEL
842
964
  # HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
965
+ # HARNESS_RUN_ACTORS mirrors RUN_ACTORS_VARIABLE
843
966
  # TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
844
967
  # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
845
968
  # COMMAND_HANDLE mirrors COMMAND_HANDLE
@@ -1027,13 +1150,18 @@
1027
1150
  # "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
1028
1151
  # answering `api repos/o/r/collaborators/alice/permission` with
1029
1152
  # {"permission":"write"}; export GITHUB_EVENT_NAME=issues GITHUB_EVENT_PATH=e.json
1030
- # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0:
1153
+ # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0 HARNESS_RUN_ACTORS='*':
1031
1154
  # trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
1032
1155
  # the prompt commit, "$s.log" gains `workflow run harness-run.yml
1033
1156
  # --ref add_comments ...`, `issue comment 7 ...` naming the branch,
1034
1157
  # then `issue edit 7 ... --remove-label sdlc-harness`
1035
1158
  # read the permission answer {"permission":"read"} -> 2, no `workflow
1036
1159
  # run`, one comment naming write access, the label removed
1160
+ # not listed HARNESS_RUN_ACTORS=bob -> 2, no `workflow run`, one comment
1161
+ # naming HARNESS_RUN_ACTORS, the label removed
1162
+ # unset HARNESS_RUN_ACTORS= and e.json gaining
1163
+ # "repository":{"owner":{"login":"alice","type":"User"}} -> 0, as
1164
+ # `trigger`; the owner `bob` instead -> 2, the comment naming @bob
1037
1165
  # ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
1038
1166
  # dispatch GITHUB_EVENT_NAME=repository_dispatch GITHUB_RUN_ID=9
1039
1167
  # GITHUB_STEP_SUMMARY=/tmp/s, e.json {"action":<TRIGGER_DISPATCH_EVENT_TYPE>,
@@ -1103,7 +1231,7 @@ LEGACY_TRIGGER_LABEL='harness'
1103
1231
  TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1104
1232
  WORKFLOW_CONTROL_FILE='harness-control.yml'
1105
1233
  COMMAND_HANDLE='@sdlc-harness'
1106
- COMMAND_VERBS='answer pause resume stop clear'
1234
+ COMMAND_VERBS='answer pause resume stop clear status'
1107
1235
  COMMENT_MARKER='<!-- sdlc-harness'
1108
1236
  REVIEW_ROUND_STATE='changes_requested'
1109
1237
  STATE_LABEL_PREFIX='sdlc-harness: '
@@ -1165,7 +1293,7 @@ usage() {
1165
1293
  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
1294
  echo " remote-run.sh pause <branch> [--repo <root>]" >&2
1167
1295
  echo " remote-run.sh warm [--repo <root>]" >&2
1168
- echo " remote-run.sh stop <branch> [--actor <login>] [--repo <root>]" >&2
1296
+ echo " remote-run.sh stop <branch> [--actor <login>] [--note <text>] [--pr <n>] [--branch-gone] [--repo <root>]" >&2
1169
1297
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
1170
1298
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
1171
1299
  echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
@@ -1282,6 +1410,7 @@ source_arg=""
1282
1410
  reviewers_arg=""
1283
1411
  allow_no_run=0
1284
1412
  pr_arg=""
1413
+ branch_gone=0
1285
1414
 
1286
1415
  while [ "$#" -gt 0 ]; do
1287
1416
  case "$1" in
@@ -1314,7 +1443,7 @@ while [ "$#" -gt 0 ]; do
1314
1443
  [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1315
1444
  review_file="$2"; shift 2 ;;
1316
1445
  --note)
1317
- [ "$verb" = report ] || usage "$1 is a report option"
1446
+ [ "$verb" = report ] || [ "$verb" = stop ] || usage "$1 is a report or stop option"
1318
1447
  [ "$#" -ge 2 ] || usage "$1 needs a value"
1319
1448
  report_note="$2"; shift 2 ;;
1320
1449
  --actor)
@@ -1333,12 +1462,15 @@ while [ "$#" -gt 0 ]; do
1333
1462
  [ "$verb" = review ] || usage "$1 is a review option"
1334
1463
  allow_no_run=1; shift ;;
1335
1464
  --pr)
1336
- [ "$verb" = collect ] || usage "$1 is a collect option"
1465
+ [ "$verb" = collect ] || [ "$verb" = stop ] || usage "$1 is a collect or stop option"
1337
1466
  [ "$#" -ge 2 ] || usage "$1 needs a value"
1338
1467
  case "$2" in
1339
1468
  ''|*[!0-9]*|0*) usage "--pr needs a positive pull request number" ;;
1340
1469
  esac
1341
1470
  pr_arg="$2"; shift 2 ;;
1471
+ --branch-gone)
1472
+ [ "$verb" = stop ] || usage "$1 is a stop option"
1473
+ branch_gone=1; shift ;;
1342
1474
  -*)
1343
1475
  usage "unknown option '$1'" ;;
1344
1476
  *)
@@ -1634,29 +1766,48 @@ verb_pause() {
1634
1766
  echo "remote-run.sh: dispatched action=pause for $branch"
1635
1767
  }
1636
1768
 
1637
- verb_warm() {
1638
- local default_branch
1769
+ # github_default_branch_var — GITHUB_DEFAULT_BRANCH, GitHub's own default
1770
+ # branch; exits 3 when it cannot be read.
1771
+ GITHUB_DEFAULT_BRANCH=""
1772
+ github_default_branch_var() {
1639
1773
  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
1774
+ GITHUB_DEFAULT_BRANCH=$(printf '%s' "$GH_OUT" | jq -r '.defaultBranchRef.name // empty' 2>/dev/null)
1775
+ if ! valid_branch "$GITHUB_DEFAULT_BRANCH"; then
1642
1776
  GH_ERR="no defaultBranchRef.name in its output"
1643
1777
  gh_fail "reading GitHub's default branch failed"
1644
1778
  fi
1779
+ }
1780
+
1781
+ verb_warm() {
1782
+ local default_branch
1783
+ github_default_branch_var
1784
+ default_branch="$GITHUB_DEFAULT_BRANCH"
1645
1785
  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
1786
  echo "remote-run.sh: dispatched action=warm on $default_branch"
1647
1787
  }
1648
1788
 
1649
1789
  verb_stop() {
1650
- local ids id failed=0 first_err="" registry stopped_at
1790
+ local ids id failed=0 first_err="" registry stopped_at marker_ref="$branch" fields=databaseId,displayTitle,status
1791
+ local gone="" gone_sha="" note
1651
1792
  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"
1793
+ if [ "$branch_gone" -eq 1 ]; then
1794
+ github_default_branch_var
1795
+ marker_ref="$GITHUB_DEFAULT_BRANCH"
1796
+ gone=gone
1797
+ fields="$fields,headSha,createdAt"
1798
+ fi
1799
+ 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
1800
  echo "remote-run.sh: dispatched the action=stop marker for $branch"
1654
1801
 
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"
1802
+ gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json "$fields" --limit 100 || gh_fail "listing the runs of '$branch' failed"
1656
1803
  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
1804
  GH_ERR="its run list is not the expected JSON"
1658
1805
  gh_fail "listing the runs of '$branch' failed"
1659
1806
  }
1807
+ if [ -n "$gone" ]; then
1808
+ gone_sha=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" \
1809
+ '[.[] | select(.displayTitle == $t)] | max_by(.createdAt // "") | .headSha // empty' 2>/dev/null) || gone_sha=""
1810
+ fi
1660
1811
  for id in $ids; do
1661
1812
  if gh_call run cancel "$id"; then
1662
1813
  echo "remote-run.sh: asked GitHub to cancel run $id of $branch"
@@ -1677,11 +1828,14 @@ verb_stop() {
1677
1828
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
1678
1829
  fi
1679
1830
  echo "remote-run.sh: stopped $branch"
1680
- if [ -n "$actor_arg" ]; then
1681
- forge_report stopped "$branch" "Stopped by @$actor_arg."
1831
+ if [ -n "$report_note" ]; then
1832
+ note="$report_note"
1833
+ elif [ -n "$actor_arg" ]; then
1834
+ note="Stopped by @$actor_arg."
1682
1835
  else
1683
- forge_report stopped "$branch" "Stopped from a local \`remote-run.sh stop\`."
1836
+ note="Stopped from a local \`remote-run.sh stop\`."
1684
1837
  fi
1838
+ forge_report stopped "$branch" "$note" "$pr_arg" "$gone" "$gone_sha"
1685
1839
  }
1686
1840
 
1687
1841
  # list_runs — the branch's runs of the workflow into GH_OUT; exits 3 on failure.
@@ -2434,6 +2588,27 @@ remote_branch_stopped() {
2434
2588
  [ "$verdict" = stopped ]
2435
2589
  }
2436
2590
 
2591
+ # remote_branch_exists <branch> — 0 when origin lists refs/heads/<branch>, 1
2592
+ # when `ls-remote` answers 2 (no such head), 2 on any other failure, with
2593
+ # REMOTE_BRANCH_ERR holding its exit status and first stderr line.
2594
+ REMOTE_BRANCH_ERR=""
2595
+ remote_branch_exists() {
2596
+ local errfile status
2597
+ REMOTE_BRANCH_ERR=""
2598
+ errfile=$(mktemp) || { REMOTE_BRANCH_ERR="mktemp failed"; return 2; }
2599
+ git -C "$root" ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>"$errfile"
2600
+ status=$?
2601
+ case "$status" in
2602
+ 0) rm -f "$errfile"; return 0 ;;
2603
+ 2) rm -f "$errfile"; return 1 ;;
2604
+ esac
2605
+ IFS= read -r REMOTE_BRANCH_ERR <"$errfile" || :
2606
+ [ -n "$REMOTE_BRANCH_ERR" ] || REMOTE_BRANCH_ERR="(no stderr)"
2607
+ REMOTE_BRANCH_ERR="git ls-remote exited $status: $REMOTE_BRANCH_ERR"
2608
+ rm -f "$errfile"
2609
+ return 2
2610
+ }
2611
+
2437
2612
  # redispatch <engine> <chain> — `dispatch <branch> --engine <engine> --resume
2438
2613
  # pause --chain <chain>` for the global branch, in a subshell so dispatch's own
2439
2614
  # exits stay its own. On failure REDISPATCH_ERR holds its first stderr line.
@@ -2495,6 +2670,11 @@ continue_redispatch() {
2495
2670
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
2496
2671
  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
2672
  esac
2673
+ remote_branch_exists "$branch"
2674
+ case $? in
2675
+ 1) echo "remote-run.sh: $branch no longer exists on origin; not re-dispatched"; return 0 ;;
2676
+ 2) echo "remote-run.sh: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2677
+ esac
2498
2678
  if ! max_chain_var; then
2499
2679
  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
2680
  return 0
@@ -2519,6 +2699,11 @@ continue_wait_poller() {
2519
2699
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
2520
2700
  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
2701
  esac
2702
+ remote_branch_exists "$branch"
2703
+ case $? in
2704
+ 1) echo "remote-run.sh: $branch no longer exists on origin; the resume poller is not enabled"; return 0 ;;
2705
+ 2) echo "remote-run.sh: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2706
+ esac
2522
2707
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
2523
2708
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
2524
2709
  else
@@ -2684,6 +2869,11 @@ poll_branch() {
2684
2869
  0) echo "remote-run.sh: poll: $branch is stopped ($STOPPED_LINE); skipped"; return 1 ;;
2685
2870
  2) echo "remote-run.sh: poll: the stop-marker check for $branch failed ($GH_ERR); skipped"; return 1 ;;
2686
2871
  esac
2872
+ remote_branch_exists "$branch"
2873
+ case $? in
2874
+ 1) poll_state_drop "$branch"; echo "remote-run.sh: poll: $branch no longer exists on origin; skipped"; return 1 ;;
2875
+ 2) echo "remote-run.sh: poll: whether $branch exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
2876
+ esac
2687
2877
  if [ "$state" != completed ]; then
2688
2878
  # Never dispatched from here: the job's own `continue` step enables the
2689
2879
  # poller after its upload, and a later tick sees the run completed.
@@ -3314,13 +3504,73 @@ trigger_bot_listed() {
3314
3504
  return 1
3315
3505
  }
3316
3506
 
3507
+ # run_actor_listed <login> — 0 when HARNESS_RUN_ACTORS admits <login>: split on
3508
+ # `,`, each entry trimmed, empty entries dropped, matched case-insensitively,
3509
+ # `*` admitting everyone, an empty <login> admitted by `*` alone. A list with no
3510
+ # entries is unset, and admits only `.repository.owner.login` of the event file
3511
+ # when `.repository.owner.type` is `User`; any other owner, or an event file or
3512
+ # field that cannot be read, admits nobody. Otherwise 1, with RUN_ACTORS_WHY
3513
+ # holding one sentence. Lowercased with `tr`, never a parameter expansion:
3514
+ # bash 3.2 has no case-changing one.
3515
+ RUN_ACTORS_WHY=""
3516
+ run_actor_listed() {
3517
+ local login="$1" want rest="${HARNESS_RUN_ACTORS-}," entry listed="" owner="" owner_type=""
3518
+ RUN_ACTORS_WHY=""
3519
+ want=$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')
3520
+ while [ -n "$rest" ]; do
3521
+ entry=${rest%%,*}
3522
+ rest=${rest#*,}
3523
+ entry=${entry#"${entry%%[![:space:]]*}"}
3524
+ entry=${entry%"${entry##*[![:space:]]}"}
3525
+ [ -n "$entry" ] || continue
3526
+ listed=1
3527
+ [ "$entry" = '*' ] && return 0
3528
+ [ -n "$want" ] && [ "$(printf '%s' "$entry" | tr '[:upper:]' '[:lower:]')" = "$want" ] && return 0
3529
+ done
3530
+ if [ -n "$listed" ]; then
3531
+ RUN_ACTORS_WHY="@$login is not on the repository variable HARNESS_RUN_ACTORS, the allow-list of who may start, command, answer or review a run."
3532
+ return 1
3533
+ fi
3534
+ if [ -n "${GITHUB_EVENT_PATH-}" ] && [ -r "$GITHUB_EVENT_PATH" ]; then
3535
+ event_field '.repository.owner.type // ""' && owner_type="$EVENT_VALUE"
3536
+ event_field '.repository.owner.login // ""' && owner="$EVENT_VALUE"
3537
+ fi
3538
+ if [ "$owner_type" = User ] && [ -n "$owner" ]; then
3539
+ [ -n "$want" ] && [ "$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" = "$want" ] && return 0
3540
+ RUN_ACTORS_WHY="@$login is not the repository owner, and the repository variable HARNESS_RUN_ACTORS is unset, which admits the owner, @$owner, alone."
3541
+ return 1
3542
+ fi
3543
+ RUN_ACTORS_WHY="the repository variable HARNESS_RUN_ACTORS is unset, and this repository has no single owner to admit (its owner is ${owner_type:-unreadable}), so it admits nobody until it names the logins allowed, or * for every writer."
3544
+ return 1
3545
+ }
3546
+
3547
+ # rerun_actor_listed — 0 unless this job is a re-run (GITHUB_RUN_ATTEMPT above
3548
+ # 1) whose GITHUB_TRIGGERING_ACTOR is neither `github-actions[bot]` nor
3549
+ # admitted by run_actor_listed; then 1, with RUN_ACTORS_WHY naming the
3550
+ # re-runner. A re-run replays the original event, whose sender is the person
3551
+ # who first acted, so authorise_actor alone would check the wrong account.
3552
+ rerun_actor_listed() {
3553
+ local attempt="${GITHUB_RUN_ATTEMPT-}" who="${GITHUB_TRIGGERING_ACTOR-}"
3554
+ RUN_ACTORS_WHY=""
3555
+ case "$attempt" in ''|*[!0-9]*) return 0 ;; esac
3556
+ [ "$attempt" -gt 1 ] || return 0
3557
+ [ "$who" != 'github-actions[bot]' ] || return 0
3558
+ run_actor_listed "$who" && return 0
3559
+ RUN_ACTORS_WHY="this job is a re-run by @${who:-(no actor)}, and the re-runner is checked rather than the event's sender: $RUN_ACTORS_WHY"
3560
+ return 1
3561
+ }
3562
+
3317
3563
  # authorise_actor <login> <type> — the one actor check, shared by `trigger` and
3318
3564
  # `control`: 0 when authorised. Otherwise AUTH_WHY holds one sentence and the
3319
3565
  # status names the arm: 1 `ghost`, empty or not a login shape; 2 a non-`User`
3320
3566
  # not listed in HARNESS_TRIGGER_ALLOWED_BOTS, decided with no permission call,
3321
- # because the permission API answers `none` or 404 for a bot; 3 a `User` whose
3322
- # permission is not `admin` or `write` (AUTH_PERMISSION holds it); 4 that
3323
- # permission call failed (GH_ERR holds why). Prints nothing and never posts.
3567
+ # because the permission API answers `none` or 404 for a bot, and never
3568
+ # consulting the run-actor list; 3 a `User` whose permission is not `admin` or
3569
+ # `write` (AUTH_PERMISSION holds it); 4 that permission call failed (GH_ERR
3570
+ # holds why); 5 a writer `run_actor_listed` refuses. The list is checked after
3571
+ # the permission call so a non-writer's refusal keeps naming write access, and
3572
+ # 5 is kept for a writer the maintainer has not named. Prints nothing and
3573
+ # never posts.
3324
3574
  AUTH_WHY=""
3325
3575
  AUTH_PERMISSION=""
3326
3576
  authorise_actor() {
@@ -3344,7 +3594,10 @@ authorise_actor() {
3344
3594
  fi
3345
3595
  AUTH_PERMISSION=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || AUTH_PERMISSION=""
3346
3596
  case "$AUTH_PERMISSION" in
3347
- admin|write) return 0 ;;
3597
+ admin|write)
3598
+ run_actor_listed "$login" && return 0
3599
+ AUTH_WHY="$RUN_ACTORS_WHY"
3600
+ return 5 ;;
3348
3601
  esac
3349
3602
  AUTH_WHY="GitHub reports the permission of @$login as ${AUTH_PERMISSION:-nothing}, not write or admin."
3350
3603
  return 3
@@ -3444,7 +3697,9 @@ verb_trigger() {
3444
3697
  { event_field '.action // ""' && action="$EVENT_VALUE" \
3445
3698
  && event_field '.client_payload.title // ""' && title="$EVENT_VALUE" \
3446
3699
  && event_field '.client_payload.body // ""' && body="$EVENT_VALUE" \
3447
- && event_field '.client_payload.source // ""' && source="$EVENT_VALUE"; } || {
3700
+ && event_field '.client_payload.source // ""' && source="$EVENT_VALUE" \
3701
+ && event_field '.sender.login // ""' && login="$EVENT_VALUE" \
3702
+ && event_field '.sender.type // ""' && sender_type="$EVENT_VALUE"; } || {
3448
3703
  echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
3449
3704
  exit "$EXIT_USAGE"
3450
3705
  }
@@ -3480,6 +3735,26 @@ verb_trigger() {
3480
3735
  "Set both keys on the default branch, then $retry_then."
3481
3736
  fi
3482
3737
 
3738
+ # A re-run replays the event's sender; the re-runner is held to the list.
3739
+ if ! rerun_actor_listed; then
3740
+ trigger_refuse "$RUN_ACTORS_WHY" \
3741
+ "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can $retry_then."
3742
+ fi
3743
+
3744
+ # A dispatch's `User` sender is held to the allow-list with no permission
3745
+ # call; a sender with no type fails closed, since an empty login is admitted
3746
+ # by `*` alone; any other sender type is the token's, and passes.
3747
+ if [ "$trigger_source" = dispatch ]; then
3748
+ if [ -z "$sender_type" ] && ! run_actor_listed ""; then
3749
+ trigger_refuse "the dispatch's sender carries no account type, so it cannot be checked against the allow-list." \
3750
+ "Set the repository variable \`HARNESS_RUN_ACTORS\` to \`*\` to admit every token holder, then $retry_then."
3751
+ fi
3752
+ if [ "$sender_type" = User ] && ! run_actor_listed "$login"; then
3753
+ trigger_refuse "$RUN_ACTORS_WHY" \
3754
+ "Add \`$login\` to the comma-separated repository variable \`HARNESS_RUN_ACTORS\`, or set it to \`*\` to admit every writer, then $retry_then."
3755
+ fi
3756
+ fi
3757
+
3483
3758
  if [ "$trigger_source" = issue ]; then
3484
3759
  if [ "$state" != open ]; then
3485
3760
  trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
@@ -3495,6 +3770,8 @@ verb_trigger() {
3495
3770
  "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`." ;;
3496
3771
  3) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${AUTH_PERMISSION:-nothing}\`." \
3497
3772
  "Only a collaborator with write, maintain or admin access starts a run by labelling an issue; one of them can re-apply the label \`$trigger_label\`." ;;
3773
+ 5) trigger_refuse "$AUTH_WHY" \
3774
+ "Add \`$login\` to the comma-separated repository variable \`HARNESS_RUN_ACTORS\`, or set it to \`*\` to admit every writer, then re-apply the label \`$trigger_label\`." ;;
3498
3775
  *) trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
3499
3776
  "Re-apply the label \`$trigger_label\` to try again." ;;
3500
3777
  esac
@@ -3667,7 +3944,7 @@ forge_marker() {
3667
3944
  FORGE_ISSUE=""
3668
3945
  FORGE_TRIGGER_LABEL=""
3669
3946
  forge_issue_var() {
3670
- local state_rel rel prompt line rest num prefix label
3947
+ local state_rel rel prompt
3671
3948
  FORGE_ISSUE=""
3672
3949
  FORGE_TRIGGER_LABEL=""
3673
3950
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
@@ -3677,6 +3954,41 @@ forge_issue_var() {
3677
3954
  fi
3678
3955
  rel=$(hr_task_prompt_rel "$state_rel" "$1")
3679
3956
  prompt=$(git -C "$root" show "refs/remotes/origin/$1:$rel" 2>/dev/null) || return 0
3957
+ forge_provenance_parse "$prompt"
3958
+ }
3959
+
3960
+ # forge_issue_at_commit_var <branch> <sha> — forge_issue_var for a branch
3961
+ # GitHub no longer has: the task prompt is read through the contents API at
3962
+ # <sha>. A failed read is one line and leaves both empty.
3963
+ forge_issue_at_commit_var() {
3964
+ local state_rel path
3965
+ FORGE_ISSUE=""
3966
+ FORGE_TRIGGER_LABEL=""
3967
+ if ! [[ "${2-}" =~ ^[0-9a-f]{7,40}$ ]]; then
3968
+ echo "remote-run.sh: report: no \`harness run $1\` run names a commit; the issue of $1 is unknown" >&2
3969
+ return 1
3970
+ fi
3971
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3972
+ if [ -z "$state_rel" ]; then
3973
+ echo "remote-run.sh: cannot resolve the state directory under '$root'" >&2
3974
+ return 1
3975
+ fi
3976
+ GH_ERR="its path could not be encoded"
3977
+ path=$(jq -rn --arg p "$(hr_task_prompt_rel "$state_rel" "$1")" '$p | split("/") | map(@uri) | join("/")') || path=""
3978
+ if [ -z "$path" ] || ! gh_call api "repos/$FORGE_REPO/contents/$path?ref=$2" -H "Accept: application/vnd.github.raw"; then
3979
+ echo "remote-run.sh: report: reading the task prompt of $1 at $2 failed: $GH_ERR" >&2
3980
+ return 1
3981
+ fi
3982
+ forge_provenance_parse "$GH_OUT"
3983
+ }
3984
+
3985
+ # forge_provenance_parse <prompt> — FORGE_ISSUE and FORGE_TRIGGER_LABEL from
3986
+ # the last provenance line of <prompt>, matched against this repository's own
3987
+ # issue URL only.
3988
+ forge_provenance_parse() {
3989
+ local prompt="$1" line rest num prefix label
3990
+ FORGE_ISSUE=""
3991
+ FORGE_TRIGGER_LABEL=""
3680
3992
  prefix="Started from $FORGE_SERVER/$FORGE_REPO/issues/"
3681
3993
  while IFS= read -r line; do
3682
3994
  case "$line" in
@@ -3801,10 +4113,20 @@ forge_utc() {
3801
4113
 
3802
4114
  # forge_question_body <out_file> <branch> <n> <open_count> <clar_dir> [<note>] —
3803
4115
  # 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).
4116
+ # file's bytes less every line naming `answer_<n>.md` (the file channel's own
4117
+ # answer line; another index's stays), cut at the last whole line within
4118
+ # QUESTION_COMMENT_MAX_BYTES when it is over it (measured in bytes; `${#…}`
4119
+ # counts characters), then the answer form and its copy block. Leaves
4120
+ # <out_file>.q and, when cut, <out_file>.cut for the caller to remove.
3806
4121
  forge_question_body() {
3807
- local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last
4122
+ local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last rc
4123
+ [ -r "$qfile" ] || return 1
4124
+ # -a and LC_ALL=C: a question is text whatever bytes it holds. Status 1 is
4125
+ # every line dropped, not a failure.
4126
+ rc=0
4127
+ LC_ALL=C grep -avF "answer_$n.md" "$qfile" >"$out.q" || rc=$?
4128
+ [ "$rc" -le 1 ] || return 1
4129
+ qfile="$out.q"
3808
4130
  size=$(wc -c <"$qfile") || return 1
3809
4131
  size=$((size))
3810
4132
  {
@@ -3826,17 +4148,21 @@ forge_question_body() {
3826
4148
  "$HR_REMOTE_CLARIFY_DIR" "$br" "$n" "$STATE_ARTIFACT_NAME"
3827
4149
  printf '\nAnswer with a comment whose first line is `%s answer %s` and whose following lines are your answer.' "$COMMAND_HANDLE" "$n"
3828
4150
  [ "$count" -ne 1 ] || printf ' This is the only open question, so `%s` may be left out: `%s answer`.' "$n" "$COMMAND_HANDLE"
3829
- printf '\n'
4151
+ printf '\n\n```\n%s answer %s\n<your answer>\n```\n' "$COMMAND_HANDLE" "$n"
3830
4152
  [ -z "$note" ] || printf '\n%s\n' "$note"
3831
4153
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3832
4154
  } >>"$out"
3833
4155
  }
3834
4156
 
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.
4157
+ # forge_report <event> <branch> [<note> [<pr> [gone [<sha>]]]] — one lifecycle
4158
+ # comment (on `parked`, one per open question) and the state label, by the
4159
+ # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4160
+ # that is the target whatever its state; `gone`, the branch deleted on GitHub,
4161
+ # its issue read from the task prompt at <sha>. Every event but `stopped` is
4162
+ # withheld when the branch's newest `harness stop` run is newer than its newest
4163
+ # `harness run` run, read from a fresh listing. Always 0.
3838
4164
  forge_report() {
3839
- local event="$1" br="$2" note="${3-}" state reason="" resume_at="" when registry_file
4165
+ local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
3840
4166
  local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
3841
4167
  case "$event" in
3842
4168
  parked|park_loop) state=parked ;;
@@ -3861,7 +4187,9 @@ forge_report() {
3861
4187
  fi
3862
4188
  forge_repo_var || return 0
3863
4189
 
3864
- if [ "$event" = failed ]; then
4190
+ if [ "$event" != stopped ]; then
4191
+ # Re-listed: `review` dispatches a run and then reports `round` in one invocation.
4192
+ ALL_RUNS_LISTED=0
3865
4193
  remote_branch_stopped "$br"
3866
4194
  stopped=$?
3867
4195
  if [ "$stopped" -eq 0 ]; then
@@ -3869,13 +4197,25 @@ forge_report() {
3869
4197
  return 0
3870
4198
  fi
3871
4199
  [ "$stopped" -eq 1 ] \
3872
- || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
4200
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the $event" >&2
3873
4201
  fi
3874
4202
 
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
4203
+ [ "$event" = stopped ] || { pr=""; gone=""; }
4204
+ if [ -n "$gone" ]; then
4205
+ forge_issue_at_commit_var "$br" "$gone_sha" || FORGE_ISSUE=""
4206
+ else
4207
+ forge_fetch_branch "$br"
4208
+ forge_issue_var "$br" || FORGE_ISSUE=""
4209
+ fi
4210
+ if [ -n "$pr" ]; then
4211
+ FORGE_PR="$pr"
4212
+ elif [ -n "$gone" ]; then
4213
+ # GitHub closes a pull request whose head is deleted, so none is open.
4214
+ FORGE_PR=""
4215
+ else
4216
+ forge_pr_var "$br" || FORGE_PR=""
4217
+ fi
4218
+ if [ -z "$pr" ] && [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
3879
4219
  echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
3880
4220
  FORGE_PR=""
3881
4221
  fi
@@ -3905,8 +4245,6 @@ forge_report() {
3905
4245
  fi ;;
3906
4246
  park_loop)
3907
4247
  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
4248
  resumed)
3911
4249
  text="The harness run on \`$br\` resumed." ;;
3912
4250
  failed)
@@ -3917,7 +4255,14 @@ forge_report() {
3917
4255
  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
4256
  fi ;;
3919
4257
  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." ;;
4258
+ if [ -n "$gone" ]; then
4259
+ 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."
4260
+ elif [ -n "$pr" ]; then
4261
+ # Only a close passes <pr>, and every command on a closed pull request is refused.
4262
+ 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."
4263
+ else
4264
+ 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."
4265
+ fi ;;
3921
4266
  round)
3922
4267
  text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
3923
4268
  esac
@@ -3926,6 +4271,10 @@ forge_report() {
3926
4271
  if [ "$event" = parked ]; then
3927
4272
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3928
4273
  [ -z "$state_rel" ] || open_questions_in "$root/${state_rel%/}"
4274
+ if [ -z "$OPEN_QUESTIONS" ]; then
4275
+ echo "::error::remote-run.sh: report: $br was classified parked with no open question; nothing posted"
4276
+ return 0
4277
+ fi
3929
4278
  fi
3930
4279
 
3931
4280
  tmp="${RUNNER_TEMP-}"
@@ -3943,7 +4292,7 @@ forge_report() {
3943
4292
  else
3944
4293
  echo "remote-run.sh: report: cannot write question $n's comment for #$target; not posted" >&2
3945
4294
  fi
3946
- rm -f "$file" "$file.cut"
4295
+ rm -f "$file" "$file.cut" "$file.q"
3947
4296
  else
3948
4297
  echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
3949
4298
  fi
@@ -4256,6 +4605,62 @@ control_state_var() {
4256
4605
  return 0
4257
4606
  }
4258
4607
 
4608
+ # control_branch_stopped <state> — 0 when <state> is unfinished (`running`,
4609
+ # `parked`, `park_loop`, `paused`) and `remote_branch_stopped` finds
4610
+ # CONTROL_BRANCH stopped; else 1, after one stderr line when the check failed.
4611
+ # The run list, not the bundle, records a stop.
4612
+ control_branch_stopped() {
4613
+ local status=0
4614
+ case "$1" in
4615
+ running|parked|park_loop|paused) ;;
4616
+ *) return 1 ;;
4617
+ esac
4618
+ remote_branch_stopped "$CONTROL_BRANCH" || status=$?
4619
+ case "$status" in
4620
+ 0) return 0 ;;
4621
+ 1) ;;
4622
+ *) echo "remote-run.sh: control: whether \`$CONTROL_BRANCH\` is stopped could not be read ($GH_ERR); the reply names the state as read" >&2 ;;
4623
+ esac
4624
+ return 1
4625
+ }
4626
+
4627
+ # control_state_word_var — called right after a successful control_state_var:
4628
+ # CS_STOPPED 1 when control_branch_stopped answers 0 for CS_STATE, else 0;
4629
+ # CS_WORD `stopped` then, else CS_STATE. Every reply names the state by
4630
+ # CS_WORD; every test of which states a verb accepts reads CS_STATE.
4631
+ CS_STOPPED=0; CS_WORD=""
4632
+ control_state_word_var() {
4633
+ CS_STOPPED=0
4634
+ CS_WORD="$CS_STATE"
4635
+ if control_branch_stopped "$CS_STATE"; then
4636
+ CS_STOPPED=1
4637
+ CS_WORD=stopped
4638
+ fi
4639
+ return 0
4640
+ }
4641
+
4642
+ # control_stopped_refuse — the refusal of a verb on a stopped run (CS_STOPPED
4643
+ # 1): names it `stopped`, the state underneath where the way on depends on it,
4644
+ # and the way on that state takes.
4645
+ control_stopped_refuse() {
4646
+ local open=""
4647
+ case "$CS_STATE" in
4648
+ parked)
4649
+ [ -z "$CS_OPEN" ] || open=", open: $CS_OPEN"
4650
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\` (it was parked, waiting for an answer$open)" \
4651
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer to question <n> on the lines below it; the answer resumes it." ;;
4652
+ park_loop)
4653
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\` (it was held by the park-loop guard)" \
4654
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4655
+ running)
4656
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`; its cancelled job is still finishing" \
4657
+ "Comment \`$COMMAND_HANDLE resume\` once it has ended." ;;
4658
+ *)
4659
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`" \
4660
+ "Comment \`$COMMAND_HANDLE resume\` to continue it from its committed ledger." ;;
4661
+ esac
4662
+ }
4663
+
4259
4664
  # control_check_branch <branch> — the refusals both paths share, in order: a
4260
4665
  # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4261
4666
  # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
@@ -4302,20 +4707,28 @@ control_branch_from_pr() {
4302
4707
  control_check_branch "$head"
4303
4708
  }
4304
4709
 
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 `'
4710
+ # control_issue_branch_var <number> — ISSUE_BRANCH, the branch of the issue's
4711
+ # last genuine start comment: by `github-actions[bot]`, its first line opening
4712
+ # with the trigger's start sentence and a backticked <b>, and its last non-empty
4713
+ # line exactly `forge_marker started <b>`. A marker anywhere else is never
4714
+ # trusted. 1 when there is none, ISSUE_BRANCH_ERR then non-empty when the
4715
+ # comments could not be read. Posts nothing.
4716
+ ISSUE_BRANCH=""
4717
+ ISSUE_BRANCH_ERR=""
4718
+ control_issue_branch_var() {
4719
+ local count i login body last b found="" lead='Started a harness run on the branch `'
4720
+ ISSUE_BRANCH=""
4721
+ ISSUE_BRANCH_ERR=""
4311
4722
  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."
4723
+ ISSUE_BRANCH_ERR="$GH_ERR"
4724
+ return 1
4313
4725
  fi
4314
4726
  count=$(printf '%s' "$GH_OUT" | jq -s 'length' 2>/dev/null) || count=""
4315
4727
  case "$count" in
4316
4728
  ''|*[!0-9]*)
4317
4729
  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." ;;
4730
+ ISSUE_BRANCH_ERR="$GH_ERR"
4731
+ return 1 ;;
4319
4732
  esac
4320
4733
  for ((i = 0; i < count; i++)); do
4321
4734
  login=$(printf '%s' "$GH_OUT" | jq -s -r --argjson i "$i" '.[$i].login // ""' 2>/dev/null) || continue
@@ -4338,17 +4751,27 @@ control_branch_from_issue() {
4338
4751
  [ "$last" = "$(forge_marker started "$b")" ] || continue
4339
4752
  found="$b"
4340
4753
  done
4341
- if [ -z "$found" ]; then
4754
+ [ -n "$found" ] || return 1
4755
+ ISSUE_BRANCH="$found"
4756
+ }
4757
+
4758
+ # control_branch_from_issue <number> — control_issue_branch_var, its failures
4759
+ # refused, then control_check_branch's refusals.
4760
+ control_branch_from_issue() {
4761
+ if ! control_issue_branch_var "$1"; then
4762
+ if [ -n "$ISSUE_BRANCH_ERR" ]; then
4763
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($ISSUE_BRANCH_ERR)" "Comment again to retry."
4764
+ fi
4342
4765
  control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4343
4766
  "Comment on the pull request of the run's branch instead."
4344
4767
  fi
4345
- control_check_branch "$found"
4768
+ control_check_branch "$ISSUE_BRANCH"
4346
4769
  }
4347
4770
 
4348
4771
  # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
4349
4772
  control_verb_handled() {
4350
4773
  case "$1" in
4351
- answer|pause|stop|resume|clear) return 0 ;;
4774
+ answer|pause|stop|resume|clear|status) return 0 ;;
4352
4775
  esac
4353
4776
  return 1
4354
4777
  }
@@ -4365,7 +4788,7 @@ control_resume_dispatch() {
4365
4788
  route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4366
4789
  route=${route#or from GitHub: }
4367
4790
  [ "$#" -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" \
4791
+ 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
4792
  "Resume it with the **Run workflow** form instead: $route$clear."
4370
4793
  fi
4371
4794
  out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
@@ -4390,11 +4813,15 @@ control_resume() {
4390
4813
  local open
4391
4814
  control_state_var "$CONTROL_BRANCH" \
4392
4815
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4816
+ control_state_word_var
4393
4817
  case "$CS_STATE" in
4394
4818
  paused)
4395
4819
  # Every pause reason, `expired` and `killed` included, resumes from the
4396
4820
  # committed ledger, as the local route does.
4397
4821
  control_resume_dispatch "Resume requested by @$CONTROL_ACTOR: \`$CONTROL_BRANCH\` continues from its committed ledger." ;;
4822
+ esac
4823
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4824
+ case "$CS_STATE" in
4398
4825
  park_loop)
4399
4826
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard" \
4400
4827
  "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
@@ -4406,20 +4833,24 @@ control_resume() {
4406
4833
  running)
4407
4834
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is already \`running\`" "Nothing needs resuming." ;;
4408
4835
  *)
4409
- control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4836
+ control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4410
4837
  "A finished run continues by a review requesting changes on its pull request, or by applying the trigger label to its issue again." ;;
4411
4838
  esac
4412
4839
  }
4413
4840
 
4414
4841
  control_clear() {
4842
+ local subject="the run"
4415
4843
  control_state_var "$CONTROL_BRANCH" \
4416
4844
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4845
+ control_state_word_var
4417
4846
  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}\`" \
4847
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4848
+ control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4419
4849
  "Only a run held by the park-loop guard is cleared."
4420
4850
  fi
4851
+ [ "$CS_STOPPED" != 1 ] || subject="the stopped run"
4421
4852
  # 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." \
4853
+ control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; $subject resumes from its committed ledger." \
4423
4854
  --park-loop-clear
4424
4855
  }
4425
4856
 
@@ -4427,8 +4858,11 @@ control_pause() {
4427
4858
  local out
4428
4859
  control_state_var "$CONTROL_BRANCH" \
4429
4860
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4861
+ control_state_word_var
4862
+ # A stopped `running` run is a cancel still finishing: nothing to pause.
4863
+ [ "$CS_STOPPED" != 1 ] || control_stopped_refuse
4430
4864
  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\`" \
4865
+ control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_WORD\`" \
4432
4866
  "Nothing needs pausing."
4433
4867
  fi
4434
4868
  out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
@@ -4463,12 +4897,102 @@ control_stop() {
4463
4897
  esac
4464
4898
  }
4465
4899
 
4900
+ # control_ledger_next_var — LEDGER_NEXT, the first `- [ ] ` line of
4901
+ # CONTROL_BRANCH's flow-progress ledger on origin's tip without that prefix,
4902
+ # and LEDGER_SECTION, the last `## ` heading above it; both empty when every
4903
+ # entry is ticked. 1 when the ledger cannot be read.
4904
+ LEDGER_NEXT=""; LEDGER_SECTION=""
4905
+ control_ledger_next_var() {
4906
+ local state_rel text line section=""
4907
+ LEDGER_NEXT=""; LEDGER_SECTION=""
4908
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
4909
+ [ -n "$state_rel" ] || return 1
4910
+ text=$(git -C "$root" show "refs/remotes/origin/$CONTROL_BRANCH:${state_rel%/}/flow_progress/${CONTROL_BRANCH}_progress.md" 2>/dev/null) \
4911
+ || return 1
4912
+ while IFS= read -r line; do
4913
+ line=${line%$'\r'}
4914
+ case "$line" in
4915
+ '## '*) section=${line#'## '} ;;
4916
+ '- [ ] '*)
4917
+ LEDGER_NEXT=${line#'- [ ] '}
4918
+ LEDGER_SECTION="$section"
4919
+ return 0 ;;
4920
+ esac
4921
+ done <<<"$text"
4922
+ return 0
4923
+ }
4924
+
4925
+ # control_round_newer_than_ledger — whether origin's tip of CONTROL_BRANCH
4926
+ # carries a user-review round commit newer than the ledger's last change: the
4927
+ # round's job writes its own ledger only after that commit. 0 when it does; 1
4928
+ # otherwise, a failed git read included. Subjects are compared as whole lines.
4929
+ control_round_newer_than_ledger() {
4930
+ local state_rel ledger_sha subjects subject line
4931
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
4932
+ [ -n "$state_rel" ] || return 1
4933
+ ledger_sha=$(git -C "$root" log -1 --format=%H "refs/remotes/origin/$CONTROL_BRANCH" \
4934
+ -- "${state_rel%/}/flow_progress/${CONTROL_BRANCH}_progress.md" 2>/dev/null) || return 1
4935
+ [ -n "$ledger_sha" ] || return 1
4936
+ subjects=$(git -C "$root" log --format=%s "$ledger_sha..refs/remotes/origin/$CONTROL_BRANCH" 2>/dev/null) \
4937
+ || return 1
4938
+ subject=$(hr_user_review_subject "$CONTROL_BRANCH")
4939
+ while IFS= read -r line; do
4940
+ [ "$line" != "$subject" ] || return 0
4941
+ done <<<"$subjects"
4942
+ return 1
4943
+ }
4944
+
4945
+ # control_status — the read-only reply: the state, the next ledger entry, the
4946
+ # open questions and the latest run. Sets no label and dispatches nothing.
4947
+ control_status() {
4948
+ local text v questions="" para=$'\n\n'
4949
+ control_state_var "$CONTROL_BRANCH" \
4950
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4951
+ control_state_word_var
4952
+ if [ "$CS_STATE" = none ]; then
4953
+ text="@$CONTROL_ACTOR: no harness run is listed for \`$CONTROL_BRANCH\`."
4954
+ else
4955
+ text="@$CONTROL_ACTOR: \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`${CS_REASON:+ (\`$CS_REASON\`)}"
4956
+ if [ "$CS_STOPPED" = 1 ]; then
4957
+ case "$CS_STATE" in
4958
+ parked) text="$text; it was parked, waiting for an answer" ;;
4959
+ park_loop) text="$text; it was held by the park-loop guard" ;;
4960
+ running) text="$text; its cancelled job is still finishing" ;;
4961
+ esac
4962
+ fi
4963
+ # An expired bundle is reported as expired, never as absent.
4964
+ [ "$CS_REASON" != expired ] || text="$text: ${CS_DETAIL:-the state bundle of the run has expired}"
4965
+ text="$text."
4966
+ fi
4967
+ if control_ledger_next_var; then
4968
+ if [ -n "$LEDGER_NEXT" ]; then
4969
+ text="$text${para}Next in the flow-progress ledger: $LEDGER_NEXT${LEDGER_SECTION:+ (under \`$LEDGER_SECTION\`)}"
4970
+ elif control_round_newer_than_ledger; then
4971
+ text="$text${para}A user-review round has started on \`$CONTROL_BRANCH\`, and its flow-progress ledger is not written yet."
4972
+ elif [ "$CS_WORD" = running ]; then
4973
+ text="$text${para}The run is still running, and its flow-progress ledger has no open entry: it is finishing its last step, or a new stage has not written its ledger yet."
4974
+ else
4975
+ text="$text${para}Every entry of the flow-progress ledger is ticked."
4976
+ fi
4977
+ else
4978
+ text="$text${para}The flow-progress ledger could not be read, so its next entry is left out."
4979
+ fi
4980
+ if [ -n "$CS_OPEN" ]; then
4981
+ for v in $CS_OPEN; do
4982
+ questions="$questions${questions:+, }$v (\`$COMMAND_HANDLE answer $v\`)"
4983
+ done
4984
+ text="$text${para}Open questions: $questions, each answered by its own comment with the answer on the lines below the command."
4985
+ fi
4986
+ [ -z "$CS_URL" ] || text="$text${para}Latest run: $CS_URL"
4987
+ control_reply "$EXIT_OK" "$text"
4988
+ }
4989
+
4466
4990
  # control_answer — one answer, one `resume: answer` dispatch with one entry. A
4467
4991
  # park left partly answered is safe: run_job stops an `answer` job whose park
4468
4992
  # is not fully answered before any session, and its bundle then carries the
4469
4993
  # `answer_<n>.md` restore wrote, so the next answer's job finds the set complete.
4470
4994
  control_answer() {
4471
- local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK"
4995
+ local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK" named
4472
4996
  first=${CONTROL_ARGS%%[$' \t']*}
4473
4997
  if [[ "$first" =~ ^[1-9][0-9]*$ ]]; then
4474
4998
  n="$first"
@@ -4494,6 +5018,14 @@ control_answer() {
4494
5018
 
4495
5019
  control_state_var "$CONTROL_BRANCH" \
4496
5020
  || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
5021
+ control_state_word_var
5022
+ # On a stopped parked run the answer dispatch is its resume; any other
5023
+ # stopped state is refused naming `stopped`.
5024
+ if [ "$CS_STOPPED" = 1 ] && [ "$CS_STATE" != parked ]; then
5025
+ control_stopped_refuse
5026
+ fi
5027
+ named="\`$CS_WORD\`"
5028
+ [ "$CS_STOPPED" != 1 ] || named="$named (it was parked)"
4497
5029
  case "$CS_STATE:$CS_REASON" in
4498
5030
  park_loop:*)
4499
5031
  control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard, not waiting for an answer" \
@@ -4510,11 +5042,11 @@ control_answer() {
4510
5042
  ;;
4511
5043
  parked:*) ;;
4512
5044
  *)
4513
- control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
5045
+ control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_WORD:-unknown}\`" \
4514
5046
  "Nothing is waiting for an answer." ;;
4515
5047
  esac
4516
5048
  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" \
5049
+ 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
5050
  "Nothing is waiting for an answer."
4519
5051
  fi
4520
5052
  for v in $CS_OPEN; do
@@ -4540,7 +5072,7 @@ control_answer() {
4540
5072
  route=${route#or from GitHub: }
4541
5073
  form="resume answer and answers \`{\"$n\": \"<the answer>\"}\`"
4542
5074
  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" \
5075
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` ($named) records no engine, and the harness does not guess one" \
4544
5076
  "Answer it with the **Run workflow** form instead: $route."
4545
5077
  fi
4546
5078
 
@@ -4579,10 +5111,18 @@ control_answer() {
4579
5111
  done
4580
5112
  if [ -n "$rest" ]; then
4581
5113
  # The label stays `parked` until the last answer: only that job resumes.
5114
+ if [ "$CS_STOPPED" = 1 ]; then
5115
+ 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."
5116
+ fi
4582
5117
  control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; question(s) $rest still need an answer: $cmds."
4583
5118
  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"
5119
+ if [ "$CS_STOPPED" = 1 ]; then
5120
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so the stopped run on \`$CONTROL_BRANCH\` resumes." \
5121
+ || status="$EXIT_GH"
5122
+ else
5123
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
5124
+ || status="$EXIT_GH"
5125
+ fi
4586
5126
  forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4587
5127
  forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4588
5128
  [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
@@ -4613,7 +5153,9 @@ control_review_story() {
4613
5153
  # `branch`'s pull request <pr_number>, shared by `control` and `collect`: every
4614
5154
  # submitted review (never `PENDING`) that requests changes or carries a
4615
5155
  # non-blank body, and every inline comment, no earlier round consumed, by
4616
- # every author `authorise_actor` accepts. What earlier rounds consumed is
5156
+ # every author `authorise_actor` accepts, the `HARNESS_RUN_ACTORS` allow-list
5157
+ # included; a refused author's items are dropped with one line, while a
5158
+ # failed permission call (status 4) fails the collection. What earlier rounds consumed is
4617
5159
  # read from the marker lines of their files on origin's tip; with none marked,
4618
5160
  # the boundary is the committer time of the newest round file. A comment
4619
5161
  # belonging to a pending review is pending whatever its `created_at`: a draft
@@ -4884,18 +5426,33 @@ control_review_in_flight() {
4884
5426
  state="\`$BS_STATE\`"
4885
5427
  [ -z "$BS_REASON" ] || state="$state (\`$BS_REASON\`)"
4886
5428
  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
5429
+ if control_branch_stopped "$BS_STATE"; then
5430
+ # A stopped run continues only by the command its underlying state takes.
5431
+ state="\`stopped\`"
5432
+ case "$BS_STATE" in
5433
+ parked)
5434
+ 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." ;;
5435
+ park_loop)
5436
+ way=" The run was stopped while held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
5437
+ running)
5438
+ way=" Its cancelled job is still finishing: comment \`$COMMAND_HANDLE resume\` once it has ended." ;;
5439
+ *)
5440
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger." ;;
5441
+ esac
5442
+ else
5443
+ case "$BS_STATE" in
5444
+ parked)
5445
+ 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." ;;
5446
+ park_loop)
5447
+ way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
5448
+ paused)
5449
+ if [ "$BS_REASON" = usage ]; then
5450
+ way=" The run resumes by itself once the usage limit resets."
5451
+ else
5452
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
5453
+ fi ;;
5454
+ esac
5455
+ fi
4899
5456
  branch="$CONTROL_BRANCH"
4900
5457
  if round_markers_read "$REVIEW_ID" && [ -n "$RC_EVENT_ROUND" ]; then
4901
5458
  control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND, which is $state on \`$CONTROL_BRANCH\`."
@@ -5054,14 +5611,194 @@ control_comment_intake() {
5054
5611
  return 0
5055
5612
  }
5056
5613
 
5614
+ # The close: CLOSE_KIND (`issue`, `pr_closed`, `pr_merged`, `deleted`) and
5615
+ # CLOSE_REF, the event's own branch (a pull request's head, a deleted ref;
5616
+ # empty for an issue). CONTROL_NUMBER is the item, empty for a deletion.
5617
+ CLOSE_KIND=""
5618
+ CLOSE_REF=""
5619
+
5620
+ control_event_unreadable() {
5621
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
5622
+ exit "$EXIT_USAGE"
5623
+ }
5624
+
5625
+ # control_issues_intake — read an `issues` event; 1 when ignored, after one line.
5626
+ control_issues_intake() {
5627
+ local action
5628
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5629
+ && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5630
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5631
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5632
+ if [ "$action" != closed ]; then
5633
+ echo "remote-run.sh: control: ignored, an issue $action, not closed"
5634
+ return 1
5635
+ fi
5636
+ CLOSE_KIND=issue
5637
+ CONTROL_VERB=close
5638
+ return 0
5639
+ }
5640
+
5641
+ # control_pull_request_intake — read a `pull_request` event; 1 when ignored,
5642
+ # after one line.
5643
+ control_pull_request_intake() {
5644
+ local action merged head_repo
5645
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5646
+ && event_field '.pull_request.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5647
+ && event_field '.pull_request.merged // false' && merged="$EVENT_VALUE" \
5648
+ && event_field '.pull_request.head.ref // ""' && CLOSE_REF="$EVENT_VALUE" \
5649
+ && event_field '.pull_request.head.repo.full_name // ""' && head_repo="$EVENT_VALUE" \
5650
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5651
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5652
+ if [ "$action" != closed ]; then
5653
+ echo "remote-run.sh: control: ignored, a pull request $action, not closed"
5654
+ return 1
5655
+ fi
5656
+ if [ -z "$head_repo" ] || [ "$head_repo" != "${GITHUB_REPOSITORY-}" ]; then
5657
+ echo "remote-run.sh: control: ignored, a pull request from ${head_repo:-an unnamed repository}, not ${GITHUB_REPOSITORY:-this repository}"
5658
+ return 1
5659
+ fi
5660
+ CLOSE_KIND=pr_closed
5661
+ [ "$merged" != true ] || CLOSE_KIND=pr_merged
5662
+ CONTROL_VERB=close
5663
+ return 0
5664
+ }
5665
+
5666
+ # control_delete_intake — read a `delete` event; 1 when ignored, after one line.
5667
+ control_delete_intake() {
5668
+ local ref_type
5669
+ { event_field '.ref // ""' && CLOSE_REF="$EVENT_VALUE" \
5670
+ && event_field '.ref_type // ""' && ref_type="$EVENT_VALUE" \
5671
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5672
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || control_event_unreadable
5673
+ if [ "$ref_type" != branch ]; then
5674
+ echo "remote-run.sh: control: ignored, a deleted ${ref_type:-ref}, not a branch"
5675
+ return 1
5676
+ fi
5677
+ if ! valid_branch "$CLOSE_REF"; then
5678
+ echo "remote-run.sh: control: ignored, a deleted branch with no valid name"
5679
+ return 1
5680
+ fi
5681
+ CLOSE_KIND=deleted
5682
+ CONTROL_VERB=close
5683
+ return 0
5684
+ }
5685
+
5686
+ # control_close_ignore <reason> — the one line every close refusal is, and exit
5687
+ # 0: a close is never replied to, labelled or dispatched for.
5688
+ control_close_ignore() {
5689
+ echo "remote-run.sh: control: close ignored: $1"
5690
+ exit "$EXIT_OK"
5691
+ }
5692
+
5693
+ # control_close — THE CLOSE (the header): the quiet gates in order, the state
5694
+ # rule, then the `stop` child with the event's note.
5695
+ control_close() {
5696
+ local status=0 protected=0 b="" what note out
5697
+ local stop_flags=()
5698
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5699
+ control_close_ignore "the repository variable HARNESS_REMOTE_STOP is set"
5700
+ fi
5701
+ forge_on || control_close_ignore "the default branch's harness.config.json does not turn run control on"
5702
+ if [ "$CLOSE_KIND" = deleted ]; then
5703
+ # Deleting a branch already needs write access; only a bot is screened,
5704
+ # never HARNESS_RUN_ACTORS (the header, THE CLOSE gate 3).
5705
+ if [ "$CONTROL_SENDER_TYPE" = Bot ] && ! trigger_bot_listed "$CONTROL_ACTOR"; then
5706
+ control_close_ignore "@$CONTROL_ACTOR is a bot not listed in HARNESS_TRIGGER_ALLOWED_BOTS"
5707
+ fi
5708
+ else
5709
+ case "$CONTROL_NUMBER" in
5710
+ ''|*[!0-9]*|0*)
5711
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5712
+ exit "$EXIT_USAGE" ;;
5713
+ esac
5714
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5715
+ case "$status" in
5716
+ 0) ;;
5717
+ 4)
5718
+ echo "::error::remote-run.sh: control: the close by @$CONTROL_ACTOR was not acted on: ${AUTH_WHY%.}"
5719
+ exit "$EXIT_GH" ;;
5720
+ *) control_close_ignore "@$CONTROL_ACTOR is not authorised: ${AUTH_WHY%.}" ;;
5721
+ esac
5722
+ fi
5723
+
5724
+ case "$CLOSE_KIND" in
5725
+ issue)
5726
+ if ! forge_repo_var; then
5727
+ echo "::error::remote-run.sh: control: the repository's name could not be read ($GH_ERR)"
5728
+ exit "$EXIT_GH"
5729
+ fi
5730
+ if ! control_issue_branch_var "$CONTROL_NUMBER"; then
5731
+ if [ -n "$ISSUE_BRANCH_ERR" ]; then
5732
+ echo "::error::remote-run.sh: control: the comments of issue #$CONTROL_NUMBER could not be read ($ISSUE_BRANCH_ERR)"
5733
+ exit "$EXIT_GH"
5734
+ fi
5735
+ control_close_ignore "no harness run was started from issue #$CONTROL_NUMBER"
5736
+ fi
5737
+ b="$ISSUE_BRANCH" ;;
5738
+ *) b="$CLOSE_REF" ;;
5739
+ esac
5740
+ if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
5741
+ control_close_ignore "\`$b\` is not a valid branch name"
5742
+ fi
5743
+ hr_branch_is_protected "$root" "$b" || protected=$?
5744
+ case "$protected" in
5745
+ 1) ;;
5746
+ 0) control_close_ignore "\`$b\` is a protected branch" ;;
5747
+ *) control_close_ignore "whether \`$b\` is protected could not be judged from harness.config.json" ;;
5748
+ esac
5749
+ CONTROL_BRANCH="$b"
5750
+ if [ "$CLOSE_KIND" = pr_closed ] || [ "$CLOSE_KIND" = pr_merged ]; then
5751
+ # GitHub closes a pull request whose head is deleted; the `delete` event's
5752
+ # own job stops that run from the default branch, so this one stays quiet.
5753
+ remote_branch_exists "$b"
5754
+ case $? in
5755
+ 1) control_close_ignore "the branch \`$b\` of pull request #$CONTROL_NUMBER is gone from origin; the deletion's own job stops the run" ;;
5756
+ 2) echo "remote-run.sh: control: whether \`$b\` exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
5757
+ esac
5758
+ fi
5759
+
5760
+ # No forge_recognised check: a merged or deleted branch may no longer carry
5761
+ # its ledger, and a listed `harness run <b>` run is what proves a harness run.
5762
+ if ! control_state_var "$b"; then
5763
+ echo "::error::remote-run.sh: control: the state of the run on \`$b\` could not be read ($CS_ERR)"
5764
+ exit "$EXIT_GH"
5765
+ fi
5766
+ case "$CS_STATE" in
5767
+ running|parked|park_loop|paused) ;;
5768
+ *) control_close_ignore "left alone: the run on \`$b\` is \`${CS_STATE:-unknown}\`" ;;
5769
+ esac
5770
+ control_branch_stopped "$CS_STATE" && control_close_ignore "the run on \`$b\` is already stopped"
5771
+
5772
+ case "$CLOSE_KIND" in
5773
+ issue) what="closed issue #$CONTROL_NUMBER" ;;
5774
+ pr_closed) what="closed pull request #$CONTROL_NUMBER"; stop_flags=(--pr "$CONTROL_NUMBER") ;;
5775
+ pr_merged) what="merged pull request #$CONTROL_NUMBER"; stop_flags=(--pr "$CONTROL_NUMBER") ;;
5776
+ deleted) what="deleted the branch \`$b\`"; stop_flags=(--branch-gone) ;;
5777
+ esac
5778
+ note="Stopped because @$CONTROL_ACTOR $what."
5779
+ if [ "$CLOSE_KIND" != deleted ]; then
5780
+ note="$note Its workflow runs and their \`$STATE_ARTIFACT_NAME\` artifacts are kept."
5781
+ fi
5782
+ echo "remote-run.sh: control: close of $b: @$CONTROL_ACTOR $what"
5783
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
5784
+ control_child "$out" stop "$b" --actor "$CONTROL_ACTOR" --note "$note" ${stop_flags[@]+"${stop_flags[@]}"} --repo "$root"
5785
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
5786
+ if [ "$CHILD_STATUS" -ne 0 ]; then
5787
+ echo "::error::remote-run.sh: control: the stop of \`$b\` after @$CONTROL_ACTOR $what failed: $CHILD_LAST"
5788
+ exit "$EXIT_GH"
5789
+ fi
5790
+ exit "$EXIT_OK"
5791
+ }
5792
+
5057
5793
  verb_control() {
5058
5794
  local LC_ALL=C
5059
- local review=0 forge="" target="" status verbs="" v
5795
+ local review=0 close=0 forge="" target="" status verbs="" v
5060
5796
  case "${GITHUB_EVENT_NAME-}" in
5061
5797
  issue_comment) ;;
5062
5798
  pull_request_review) review=1 ;;
5799
+ issues|pull_request|delete) close=1 ;;
5063
5800
  *)
5064
- echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment or pull_request_review, not '${GITHUB_EVENT_NAME-}'" >&2
5801
+ 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
5802
  exit "$EXIT_USAGE" ;;
5066
5803
  esac
5067
5804
  if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
@@ -5070,18 +5807,22 @@ verb_control() {
5070
5807
  fi
5071
5808
  hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
5072
5809
 
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" ;;
5810
+ case "$GITHUB_EVENT_NAME" in
5811
+ pull_request_review) control_review_intake || return 0 ;;
5812
+ issue_comment) control_comment_intake || return 0 ;;
5813
+ issues) control_issues_intake || return 0 ;;
5814
+ pull_request) control_pull_request_intake || return 0 ;;
5815
+ delete) control_delete_intake || return 0 ;;
5083
5816
  esac
5084
5817
 
5818
+ if [ "$close" -eq 0 ]; then
5819
+ case "$CONTROL_NUMBER" in
5820
+ ''|*[!0-9]*|0*)
5821
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5822
+ exit "$EXIT_USAGE" ;;
5823
+ esac
5824
+ fi
5825
+
5085
5826
  control_tmp="${RUNNER_TEMP-}"
5086
5827
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5087
5828
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
@@ -5089,6 +5830,9 @@ verb_control() {
5089
5830
  fi
5090
5831
  trap control_cleanup EXIT
5091
5832
 
5833
+ # Decided by the event name, never CONTROL_VERB: a comment can name `close`.
5834
+ [ "$close" -eq 0 ] || control_close
5835
+
5092
5836
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5093
5837
  control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
5094
5838
  "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
@@ -5101,11 +5845,16 @@ verb_control() {
5101
5845
  "Set both keys on the default branch, then comment again."
5102
5846
  fi
5103
5847
 
5848
+ if ! rerun_actor_listed; then
5849
+ control_refuse "$EXIT_REFUSED" "${RUN_ACTORS_WHY%.}" \
5850
+ "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
5851
+ fi
5852
+
5104
5853
  status=0
5105
5854
  authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5106
5855
  if [ "$status" -ne 0 ]; then
5107
5856
  control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
5108
- "Only a collaborator with write, maintain or admin access, or a bot listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
5857
+ "Only a collaborator with write, maintain or admin access whom the repository variable \`HARNESS_RUN_ACTORS\` admits (when unset, the owner alone of a repository a personal account owns, and nobody in an organisation-owned one), or a bot listed in \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
5109
5858
  fi
5110
5859
 
5111
5860
  if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
@@ -5133,6 +5882,7 @@ verb_control() {
5133
5882
  stop) control_stop ;;
5134
5883
  resume) control_resume ;;
5135
5884
  clear) control_clear ;;
5885
+ status) control_status ;;
5136
5886
  review) control_review ;;
5137
5887
  esac
5138
5888
  }