autonomous-sdlc-harness 0.6.1 → 0.6.3

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.
@@ -32,6 +32,8 @@
32
32
  # remote-run.sh discard <dir> [--repo <root>]
33
33
  # remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]
34
34
  # (always 0, 1 only on a usage error: its paragraph)
35
+ # remote-run.sh open <branch> [--repo <root>]
36
+ # (always 0, 1 only on a usage error: its paragraph)
35
37
  # remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]
36
38
  # (always 0, 1 only on a usage error: its paragraph)
37
39
  # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
@@ -53,9 +55,7 @@
53
55
  # 1 usage error, or the library or the configuration could not be
54
56
  # resolved; for fetch, <out_dir> is not an existing, empty directory;
55
57
  # for discard, <dir>'s parent does not resolve or the removal failed;
56
- # for sync and restore, a local copy or write failed; for
57
- # pause-requested, also NO such run — a caller that reads 1 as "no
58
- # pause" passes arguments it has already validated
58
+ # for sync and restore, a local copy or write failed
59
59
  # 2 refused, nothing sent or written: execution.target is not
60
60
  # github-actions (sending verbs, fetch, review and list); for
61
61
  # review, a protected branch, a review file that is not a readable
@@ -91,6 +91,7 @@
91
91
  # copy (the branch checked out in another working copy included), the
92
92
  # fast-forward, the commit or the push — and nothing was dispatched; a
93
93
  # copy it cut was removed
94
+ # 5 pause-requested only: the read succeeded and found no such run
94
95
  #
95
96
  # `start` IS THE ADAPTERS' ONE ENTRY: every trigger (an issue event, a forge
96
97
  # dispatch, anything later) reduces to a branch and a task text and ends here.
@@ -191,13 +192,18 @@
191
192
  # a refusal can still be commented. It reads, only from the environment:
192
193
  # GITHUB_EVENT_NAME, GITHUB_EVENT_PATH the event; each field is read by `jq`
193
194
  # into a variable and is only ever an argument or file
194
- # bytes, never shell source
195
+ # bytes, never shell source; `.repository.owner.login`
196
+ # and `.repository.owner.type` only when
197
+ # `HARNESS_RUN_ACTORS` is unset
195
198
  # GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
196
199
  # the URLs its comments name
197
200
  # HARNESS_REMOTE_STOP non-empty: every start is refused
198
201
  # HARNESS_TRIGGER_LABEL the trigger label; when empty, `DEFAULT_TRIGGER_LABEL`
199
202
  # or `LEGACY_TRIGGER_LABEL`, per the paragraph below
200
203
  # HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
204
+ # HARNESS_RUN_ACTORS comma-separated people allowed to act on a run, `*`
205
+ # for every writer; unset admits a `User` owner alone
206
+ # (`run_actor_listed`)
201
207
  # HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
202
208
  # A test seam
203
209
  # RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
@@ -226,7 +232,13 @@
226
232
  # 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
227
233
  # `write` — `maintain` reads as `write` and `triage` as `read` there; a
228
234
  # failed call is "could not confirm write access", never a pass
229
- # Refusals 4 to 6 are `authorise_actor`, the one actor check `control` reuses.
235
+ # 7. a writer `HARNESS_RUN_ACTORS` does not admit — after refusal 6, so a
236
+ # non-writer is still told about write access
237
+ # Refusals 4 to 7 are `authorise_actor`, the one actor check `control` reuses.
238
+ # Before them, after refusal 2, a re-run (GITHUB_RUN_ATTEMPT above 1) whose
239
+ # GITHUB_TRIGGERING_ACTOR is not `github-actions[bot]` and is not admitted by
240
+ # `HARNESS_RUN_ACTORS` is refused (`rerun_actor_listed`), on both event kinds:
241
+ # a re-run replays the event, so its sender is whoever first acted.
230
242
  # Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
231
243
  # branch with `hr_derive_branch <title> issue_<number>`, passing `gh` so a name
232
244
  # with run-workflow history counts as taken (2 or 3 refused), writes
@@ -252,7 +264,10 @@
252
264
  # and exit 0 with no `gh` call; an empty or missing `title` is refused. It has
253
265
  # no labeller: GitHub sends one only for a fine-grained token with Contents
254
266
  # write or a classic token with `repo`, so the token holder is the authority,
255
- # and refusals 3 to 6 do not apply. The fallback name is `task_<GITHUB_RUN_ID>`
267
+ # and refusals 3 to 6 do not apply. After refusal 2 a `User` sender
268
+ # (`.sender.login`, `.sender.type`) that `HARNESS_RUN_ACTORS` does not admit is
269
+ # refused with no permission call, and so is a sender with no type unless the
270
+ # list is `*`, failing closed; any other sender type passes. The fallback name is `task_<GITHUB_RUN_ID>`
256
271
  # and the snapshot's provenance sentence names the event type, `source` when
257
272
  # set, and the time. There is no issue, so no comment and no label: every
258
273
  # outcome is printed to stdout and appended as a Markdown block to
@@ -271,18 +286,38 @@
271
286
  # as `trigger` is, and outside the sending-verb gate: it does nothing, with one
272
287
  # line, unless `hr_forge` is `github` and `hr_execution_target` is
273
288
  # `github-actions`. The comment goes to the open same-repository pull request
274
- # whose head is <branch> when origin's <branch> carries
275
- # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
276
- # the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
289
+ # whose head is <branch> when origin's <branch> carries its task prompt or
290
+ # `<state_dir>/flow_progress/<branch>_progress.md` (`forge_recognised`), else to
291
+ # the issue named by the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
277
292
  # task prompt, else nowhere (`stop`'s `--pr` and `--branch-gone` change this
278
293
  # for its own report: its paragraph). The label `STATE_LABEL_PREFIX<state>` replaces any
279
294
  # other state label on that issue and that pull request, each when known; the
280
295
  # label is a view, and the run list stays the authority. The state map:
281
296
  # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
282
- # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
283
- # `failed` posts nothing when
284
- # `remote_branch_stopped` finds the branch stopped, so a cancelled job never
285
- # overwrites `stopped`. `completed` (deliver's) and `launched` (the trigger's
297
+ # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running,
298
+ # `not_started` (collect's) -> paused or failed, as its caller says. A
299
+ # `not_started` comment says GitHub did not start the run's job, so nothing ran
300
+ # and the branch is unchanged, and names its way on: `COMMAND_HANDLE resume`
301
+ # when the state is paused and an engine was recovered, else the **Run
302
+ # workflow** form with that engine, or with the three to choose from.
303
+ # On a pull-request target that is not a draft, `round` runs `gh pr ready
304
+ # --undo` before its comment, with the caller's own token, and its comment says
305
+ # the pull request is a draft again until the round completes; a refusal (a
306
+ # plan without drafts) is one `::warning::` line naming gh's error, and the
307
+ # comment then says turning it back was refused and it stays ready for review
308
+ # while the round works. A `failed` comment on a pull request reads the
309
+ # registry record's `engine`: for
310
+ # `user_review` it names a review requesting changes and says the pull request
311
+ # stays open; otherwise it says the draft stays open, to close to discard the
312
+ # run, or, when an issue is known, to re-apply the trigger label there for a
313
+ # new run on the next indexed branch. A plain `stopped` on a pull request says
314
+ # its draft stays open and closing it discards the run. No other event changes
315
+ # a pull request's draft state.
316
+ # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round`,
317
+ # `failed` and `not_started` — posts nothing and sets no label when `remote_branch_stopped`,
318
+ # asked afresh, finds the branch stopped, so a job a stop overtook never
319
+ # overwrites `stopped`; a failed listing reports anyway, and `stopped` is never
320
+ # withheld. `completed` (deliver's) and `launched` (the trigger's
286
321
  # own comment) are one line each, as is any other event. The comment names the
287
322
  # next GitHub action — never a slash command — then <note> byte for byte, then
288
323
  # this run's URL when `GITHUB_RUN_ID` is set, then the marker line
@@ -303,36 +338,127 @@
303
338
  # comment and its answer are public, as the artifact already is
304
339
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
305
340
  # repository's Actions runs can see*).
341
+ # `progress` (`forge_progress`) keeps ONE comment per run or round on the pull
342
+ # request, never on the issue, and sets no label. Gated in order, each stop one
343
+ # line: `forge_on`; `hr_progress_comments` (`execution.progressComments` false,
344
+ # or unreadable); `remote_branch_stopped`, as for the job events above; no task
345
+ # or user-review ledger at `$root`'s `<state_dir>/flow_progress/<branch>_progress.md`
346
+ # (`hr_ledger_phases` — a docs-engine ledger posts nothing); no open
347
+ # same-repository pull request that `forge_recognised` holds for. The body is
348
+ # *Progress of the harness run on `<branch>`:* or *Progress of user-review round
349
+ # <n> on `<branch>`:*, then `- <label>: done`, `in progress` (the first pending
350
+ # phase) or `not started` for `Planning`, `Implementation`, `Branch review`,
351
+ # `Done` (a round: `Fix plan`, `Fix implementation`, `Branch review`, `Done`) —
352
+ # plain list items, no timestamp, no run URL — then the marker
353
+ # `<!-- sdlc-harness event=progress branch=<branch> -->`, gaining ` round=<n>`
354
+ # for a round. One paginated comment listing picks the newest
355
+ # `github-actions[bot]` comment whose last non-empty line is that marker: none
356
+ # is one `forge_comment`; a body equal to the render (carriage returns and
357
+ # trailing newlines aside) is no call and one `already current` line; otherwise
358
+ # one `PATCH` of that comment — the only comment this file ever edits. A refused
359
+ # listing, create or edit is one `::warning::` line.
306
360
  # It never fails its caller: every problem is one line and exit 0.
307
361
  #
362
+ # `open` OPENS THE RUN'S DRAFT PULL REQUEST at the run's start, so its issue
363
+ # links it from the moment it exists. Job-side for its root and self-gated, as
364
+ # `deliver` is (`forge_on`, one line when off); it takes <branch> and no
365
+ # bundle. A pull-request lookup that fails is one `::warning::` line and opens
366
+ # nothing; an open same-repository pull request whose head is <branch> is one
367
+ # line, and none is opened. Otherwise `forge_open_pr` — the one opener, which
368
+ # `deliver` also calls — creates it against `defaultBranch` with `deliver`'s
369
+ # title, body, token and retry rules: a `--draft` create with
370
+ # `HARNESS_PR_TOKEN` as `GH_TOKEN` when set, no retry on `PR_CREATE_FORBIDDEN`,
371
+ # one retry without `--draft` on any other failure. Opened, the pull request is
372
+ # labelled `STATE_LABEL_PREFIX`running, and when the task prompt names an issue
373
+ # one comment there names it — as a draft, or as not a draft because the
374
+ # repository's plan may not offer drafts — says the run's questions, lifecycle
375
+ # comments and progress go to it from now on while `COMMAND_HANDLE` commands
376
+ # keep working on the issue, adds this run's URL when `GITHUB_RUN_ID` is set,
377
+ # and ends with the `opened` marker. With no issue nothing is posted. Not
378
+ # opened is one `::warning::` line naming why and saying the run goes on, its
379
+ # comments reach the issue and `deliver` tries again at the run's end; nothing
380
+ # is posted, since `deliver`'s `completed` comment is the one notification.
381
+ # It never fails its caller: every outcome but a usage error is exit 0.
382
+ #
308
383
  # `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
309
384
  # job's own `push-branch.sh`, which opens no pull request. Job-side for its
310
385
  # root and self-gated, as `report` is (`forge_on`, one line when off). Anything
311
386
  # but `status: completed` in <bundle_dir>/status.json, or no status.json, is
312
- # one line and nothing sent. Otherwise, by the forge surface: an open
313
- # same-repository pull request whose head is <branch> is reused and no second
314
- # one is opened; else a draft is created against `defaultBranch`, titled from
315
- # the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
316
- # <branch>), whose body names the issue as `Started from #<n>.` — a plain
317
- # mention, never a closing keyword — states what a review requesting changes
318
- # and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
319
- # marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
320
- # set, so the adopter's CI runs without an approval click; every other call
321
- # uses the job's token. A pull request so opened is authored by that token's
322
- # owner, who therefore cannot request changes on it: use a machine account's
323
- # token, another reviewer, or a local `branch-user-review` round. A create
324
- # refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
325
- # Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
326
- # without `--draft`, and a second failure is named in the comment with the
327
- # branch's compare URL. A pull-request lookup that fails opens nothing. Then
328
- # one `completed` comment: on the issue naming the new pull request's URL; on
329
- # the pull request when there is no issue, or when it existed before this run
330
- # (saying the round finished); on the issue alone when none could be opened;
331
- # nowhere when neither is known. It names reviewing and requesting changes as
332
- # the next action, and with `phases.qa` true the local `branch-qa-test` still
333
- # owed. Then `sdlc-harness: done` on the issue and the pull request, each when
334
- # known. It writes at most one pull request, one comment and those labels, and
335
- # never pushes. It never fails its caller: every problem is one line and exit 0.
387
+ # one line and nothing sent. The bundle's `engine` names what completed:
388
+ # `user_review` is a round, anything else a run. An open same-repository pull
389
+ # request whose head is <branch> is reused and no second one is opened. With
390
+ # none open and a lookup that succeeded, `forge_open_pr` is the fallback — one
391
+ # line says none was open at completion, because the start's attempt failed or
392
+ # the workflow predates `open`. It is created against `defaultBranch`, titled
393
+ # from the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
394
+ # <branch>), with a body naming the issue as `Started from #<n>.` — a plain
395
+ # mention, never a closing keyword — stating what a review requesting changes
396
+ # and the `COMMAND_HANDLE` commands do, and ending with the `pull-request`
397
+ # marker: a `--draft` create with `HARNESS_PR_TOKEN` as `GH_TOKEN` when set, so
398
+ # the adopter's CI runs without an approval click, no retry on
399
+ # `PR_CREATE_FORBIDDEN`, one retry without `--draft` on any other failure. A
400
+ # pull request so opened is authored by that token's owner, who therefore
401
+ # cannot request changes on it: use a machine account's token, another
402
+ # reviewer, or a local `branch-user-review` round. A lookup that fails opens
403
+ # nothing. Then the ready flip, with the job's token and never
404
+ # `HARNESS_PR_TOKEN`: a draft gets one `gh pr ready` (flipped), and a refusal
405
+ # is one `::warning::` line naming gh's error (refused); a pull request that is
406
+ # not a draft — no drafts on the plan, or a person marked it ready — gets no
407
+ # call (not a draft) and is left alone. A round's review threads are handled
408
+ # next, after the flip and before the comments, with the job's token, per THE
409
+ # REVIEW-COMMENT LINE below: only when the pull request is known, the
410
+ # highest-numbered round file on origin's tip is the highest-numbered marked
411
+ # one (`round_markers_read`) — a round placed by the local command consumes no
412
+ # pull-request item — and its marker's `comments=` is non-empty; otherwise one
413
+ # line. Its fix plan is `<branch>_fix_plan.md` and `<branch>_fix_plan/` for
414
+ # round 1, `<branch>_fix_plan_<n>.md` and `<branch>_fix_plan_<n>/` for round
415
+ # <n>, read from origin's tip. A verdict is kept only for an id the round
416
+ # collected: `fixed <sha>` for an implemented finding, `reason <text>` for an
417
+ # out-of-scope bullet; a collected id with none is one line, left alone. One
418
+ # paginated `api graphql` listing of the pull request's `reviewThreads` (`id`,
419
+ # `isResolved`, each comment's `databaseId`, `createdAt` and `body`); the
420
+ # thread holding a collected id is that id's thread. Left alone, one line
421
+ # each: a thread already resolved; a thread with a comment created after the
422
+ # round's `collected_at` that is not a collected id and carries no
423
+ # `COMMENT_MARKER` — the reviewer replied after the collection; and a thread
424
+ # already carrying this round's `thread` marker (` round=<n>` included), so a
425
+ # re-run of the same round replies nothing twice while a later round still
426
+ # handles a thread an earlier round replied to. Otherwise one reply, through
427
+ # `pulls/<pr>/comments/<id>/replies` to the thread's first comment, ending with
428
+ # `forge_marker thread <branch>` and the round's ` round=<n>` — whose
429
+ # `COMMENT_MARKER` keeps the next `round_collect` from collecting it: for
430
+ # `fixed`, ``Addressed in `<sha>`.`` and, only once that reply is posted, one
431
+ # `resolveReviewThread` mutation; for `reason`, `Not changed in this round:
432
+ # <reason>`, the thread left open. A refused listing, reply or resolve is one
433
+ # `::warning::` line. It never dismisses a review.
434
+ # THE REVIEW-COMMENT LINE is this file's contract, written by one writer,
435
+ # `plugin/agents/user-review-fix-plan-writer.md`, and read by `deliver` alone:
436
+ # - in a per-finding file `<state_dir>/user_reviews/<branch>_fix_plan[_<n>]/
437
+ # finding_<K>.md`, one line beginning `**Review comments:** ` followed by
438
+ # one or more comment ids separated by `, `, e.g.
439
+ # `**Review comments:** 2735551234, 2735551240`;
440
+ # - in the fix-plan index's `## Out of scope / verified-OK` section, a bullet
441
+ # (a line beginning `- `) ending with ` **Review comments:** <id>[, <id>…]`,
442
+ # its text before that suffix the reason the reply quotes;
443
+ # - the ids are the round marker's `comments=` ids, GitHub's review-comment
444
+ # `id`s; an observation from no inline comment carries no such line;
445
+ # - finding <K> is implemented when the index carries the literal
446
+ # `[x] **Finding <K>**`, and its fix commit is the oldest commit on
447
+ # `origin/<branch>` that introduced that literal into the index (`git log
448
+ # --reverse -S`), else origin's tip — the committer flips the entry in the
449
+ # fix's own commit.
450
+ # Then one `completed` comment
451
+ # on the pull request, its readiness clause by the flip's outcome, and one on
452
+ # the issue naming the pull request's number and URL, each when known; with no
453
+ # pull request, one comment on the issue alone naming why none could be opened
454
+ # — the Actions setting and `HARNESS_GIT_TOKEN` on `PR_CREATE_FORBIDDEN`, else
455
+ # gh's error — with the branch's compare URL; nowhere when neither is known.
456
+ # Each names reviewing and requesting changes as the next action, and with
457
+ # `phases.qa` true the local `branch-qa-test` still owed. Then
458
+ # `sdlc-harness: done` on the issue and the pull request, each when known. It
459
+ # writes at most one pull request, one flip, a round's thread replies and
460
+ # resolves, two comments and those labels, and never pushes. It never fails its caller: every problem, a refused flip
461
+ # included, is one line and exit 0.
336
462
  #
337
463
  # `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
338
464
  # one step of the run workflow's `collect` job, which follows its `run` job, so
@@ -340,19 +466,27 @@
340
466
  # Job-side for its root and self-gated, as `report` and `deliver` are
341
467
  # (`forge_on`, one line when off). In order, each stop one line and exit 0:
342
468
  # `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
343
- # or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
344
- # included); `branch_settled_var`, read as `control` reads it, finding the
469
+ # or failing; `branch_settled_var`, read as `control` reads it, failing; the
470
+ # newest run's job never started — when that run is this one (or
471
+ # `GITHUB_RUN_ID` is unset), `report`'s `not_started` on its pull request or
472
+ # issue, with the state and engine the read derived, and one push notification,
473
+ # no round collected, since the reviews stay for the resumed run's own end;
474
+ # when it is another run, one line, since that run's own `collect` reports it;
475
+ # no pull request, from --pr or else `forge_pr_var` (a failed lookup
476
+ # included); the settledness read finding the
345
477
  # branch in flight — a newer run listed, or this run ending `parked`,
346
478
  # `park_loop` or `paused` (a budget chain's included), whose own end collects
347
- # next — or failing; `round_collect` finding no review requesting changes
479
+ # next; `round_collect` finding no review requesting changes
348
480
  # pending (inline comments alone start no round, as *Comment* starts none; they
349
481
  # ride along in the next), or failing, a `::warning::` line. Otherwise `review
350
482
  # <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
351
483
  # <pull request url>` runs as a child, as `control` runs it, placing,
352
484
  # dispatching and reporting the round. A child that exits non-zero gets exactly
353
485
  # one comment on the pull request, with the `reply` marker, naming its last
354
- # stderr line and saying the reviews stay there and that submitting a review
355
- # requesting changes retries; there is no automatic retry. It never fails its
486
+ # stderr line and saying the reviews stay there and that, when `GITHUB_RUN_ID`
487
+ # is set, re-running this run's `collect` job retries with no new review, and
488
+ # that submitting a review requesting changes retries; there is no automatic
489
+ # retry. It never fails its
356
490
  # caller: every outcome but a usage error is exit 0.
357
491
  #
358
492
  # `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
@@ -379,7 +513,10 @@
379
513
  # 1. `HARNESS_REMOTE_STOP` is set
380
514
  # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
381
515
  # GitHub nothing about the commenter
382
- # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run
516
+ # then a re-run whose GITHUB_TRIGGERING_ACTOR `rerun_actor_listed`
517
+ # refuses, before the event's own actor is checked
518
+ # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run — a
519
+ # writer the `HARNESS_RUN_ACTORS` allow-list admits, or a listed bot
383
520
  # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
384
521
  # yet: the reply lists every command and names `docs/github-run-control.md`
385
522
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
@@ -393,8 +530,20 @@
393
530
  # marker quoted mid-body, in a comment not opening with that sentence, or by
394
531
  # anyone else, is never trusted; none is a refusal. Both paths then pass
395
532
  # `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
396
- # for is refused, then it is fetched, and one whose origin tip carries no
397
- # flow-progress ledger (`forge_recognised`) is not a harness branch.
533
+ # for is refused, then it is fetched. It is a harness branch when its origin
534
+ # tip carries its task prompt or the flow-progress ledger (`forge_recognised`)
535
+ # — the task prompt, `start`'s first commit, makes a pull request opened at
536
+ # the run's start commandable before the ledger's first push; or, for a command
537
+ # typed on an issue, when it is the branch that issue's genuine `started`
538
+ # marker names and it still exists on origin (`remote_branch_exists`) — the
539
+ # marker shows the harness started a run on it, so a first run GitHub never
540
+ # started, which committed only its task prompt, is still commandable; an
541
+ # issue keeps its marker after its branch is deleted, so a marker-named branch
542
+ # gone from origin is refused as deleted, and a failed existence check is a
543
+ # refusal naming the read; or when a `harness run <branch>` run is queued,
544
+ # waiting, requested, pending or in progress (`control_run_in_flight`), so a
545
+ # run is commandable from its first minute on its pull request too. Otherwise
546
+ # it is not a harness branch. A failed listing is a refusal naming the read.
398
547
  # THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
399
548
  # `running` sends `pause <branch>` as a child and replies that the run yields at
400
549
  # its next clean checkpoint; any other state is a refusal naming it. `stop`:
@@ -409,9 +558,11 @@
409
558
  # accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
410
559
  # any other state is a refusal naming it. Each sends the local relay's dispatch,
411
560
  # `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
412
- # `clear` with `park_loop_clear` set, and no other command sets it. An empty
413
- # engine is refused, never guessed (the `engine` input defaults to `task`),
414
- # naming the Run workflow form. On 0 a reply, then `running` on the run's issue
561
+ # `clear` with `park_loop_clear` set, and no other command sets it. A run whose
562
+ # job GitHub never started first takes the engine its dispatch's comment
563
+ # records (`forge_dispatch_engine_var`); an engine still empty after that is
564
+ # refused, never guessed (the `engine` input defaults to `task`), and the
565
+ # refusal names the Run workflow form. On 0 a reply, then `running` on the run's issue
415
566
  # and pull request; on 2 a refusal and exit 2. `answer`: the first line is
416
567
  # `answer <n>` and the answer every line below it, a trailing CR stripped from
417
568
  # each line and its bytes otherwise unchanged; text after <n> on the first line
@@ -451,8 +602,12 @@
451
602
  # first `- [ ] ` entry of the flow-progress ledger on origin's tip with the
452
603
  # `## ` heading above it, and replies once — the state by `CS_WORD` with its
453
604
  # 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
605
+ # the next ledger entry (or, on a fully ticked ledger, that a user-review round
606
+ # has started and its ledger is not written yet, when origin's tip carries the
607
+ # round's `chore: add user review for <branch>` commit after the ledger's last
608
+ # change; else, for a `running` run, that it is still finishing or starting a
609
+ # stage, never that every entry is ticked; else that every entry is ticked; or
610
+ # that the ledger could not be read), each open question with its `answer <n>` form, and the
456
611
  # latest run's URL. It sets no label and runs no child verb but `fetch`; a
457
612
  # failed state read is a refusal and exit 3.
458
613
  # THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
@@ -496,9 +651,10 @@
496
651
  # inline comment, by any author and whatever its review's state, whose id is
497
652
  # unrecorded, whose body carries no `COMMENT_MARKER`, and whose `created_at` is
498
653
  # at or after the boundary or whose review is pending. Every distinct author
499
- # of a pending item passes `authorise_actor`; a refused author's items are
500
- # dropped with one line naming the login, `AUTH_WHY` and the count, and a
501
- # failed permission call fails the collection. A round is placed only when at
654
+ # of a pending item passes `authorise_actor`, the `HARNESS_RUN_ACTORS`
655
+ # allow-list included; a refused author's items — a writer the list refuses
656
+ # among them — are dropped with one line naming the login, `AUTH_WHY` and the
657
+ # count, and a failed permission call (status 4) fails the collection. A round is placed only when at
502
658
  # least one kept review has state `REVIEW_ROUND_STATE`; a *Comment* or
503
659
  # *Approve* review rides along in the next round one requesting changes starts.
504
660
  # The file: one `## Review by @<login>` section per pending review, oldest
@@ -531,8 +687,10 @@
531
687
  # submitting a review requesting changes retries now.
532
688
  # A child's failure is a reply
533
689
  # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
534
- # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
535
- # `@<login>: `<verb>` was not run: <reason>. <way on>`.
690
+ # typed on, opens `@<login>`, and carries the `reply` marker; a reply posted
691
+ # after a successful dispatch adds ` engine=<engine>` to that marker, the engine
692
+ # the dispatch named, so a run whose job never ran can still be resumed with
693
+ # it. A refusal reads `@<login>: `<verb>` was not run: <reason>. <way on>`.
536
694
  # THE CLOSE. Whatever the run's phase, it is stopped when its issue is closed,
537
695
  # its pull request closed or merged, or its branch deleted; the routing is by
538
696
  # event name, never by verb, so a comment naming `close` is not one. `issues`
@@ -548,9 +706,11 @@
548
706
  # 1. `HARNESS_REMOTE_STOP` is set
549
707
  # 2. `forge_on` fails
550
708
  # 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
709
+ # (statuses 1–3 and 5, so a writer the `HARNESS_RUN_ACTORS` allow-list
710
+ # refuses is ignored too), the line naming the login and `AUTH_WHY`; on
711
+ # `delete`, only a `Bot` sender `trigger_bot_listed` does not list, and
712
+ # never the allow-list: deleting a branch already needs write access, the
713
+ # run's branch is gone, and a stop spends no credential
554
714
  # 4. no branch: an issue's from `control_issue_branch_var` (no genuine start
555
715
  # comment), a pull request's head ref, a deletion's `.ref`; then not a
556
716
  # valid branch name
@@ -559,8 +719,8 @@
559
719
  # answers 1): GitHub closed the pull request because the branch was
560
720
  # deleted, and the `delete` event's job stops the run; an `ls-remote` that
561
721
  # 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
722
+ # There is no harness-branch check (`forge_recognised`): a merged or deleted
723
+ # branch may no longer carry its task prompt or ledger, and a listed `harness run <b>` run is the
564
724
  # proof. The state is read by `control_state_var`: only `running`, `parked`,
565
725
  # `park_loop` and `paused` are acted on; `completed`, `failed` and `none` are
566
726
  # `left alone: the run on <b> is <state>`, and one `remote_branch_stopped`
@@ -657,7 +817,9 @@
657
817
  # HARNESS_MAX_CHAIN the automatic-dispatch limit; `24` when empty. Not a
658
818
  # non-negative integer: nothing is dispatched
659
819
  # HARNESS_POLL_MAX_DISPATCH_FAILURES `poll` only: failed re-dispatches of
660
- # one paused run before it gives up; `3` when empty
820
+ # one paused run, and consecutive failed downloads of
821
+ # one run's listed bundle, each counted apart, before it
822
+ # gives up; `3` when empty
661
823
  # HARNESS_POLL_GIVE_UP_AFTER_MINUTES `poll` only: minutes after a run's
662
824
  # `usage_resume_at` past which a failed re-dispatch gives
663
825
  # up; `360` when empty. Either one not a non-negative
@@ -668,7 +830,9 @@
668
830
  # GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
669
831
  # Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
670
832
  # `failed`, and each is then reported as `report` reports that event, with a
671
- # note of its own that names no slash command and no shell command. A re-dispatch is `dispatch <branch> --engine <status.json engine>
833
+ # note of its own that names no slash command and no shell command. The one
834
+ # exception is `poll`'s push-only `bundle_unreadable`, which posts no comment
835
+ # and sets no label. A re-dispatch is `dispatch <branch> --engine <status.json engine>
672
836
  # --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
673
837
  #
674
838
  # `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
@@ -715,7 +879,8 @@
715
879
  # than `GITHUB_RUN_ID` (bounded) carrying an unexpired `harness-poll-state`
716
880
  # artifact is downloaded to `<state_dir>/autonomous_logs/poll_state/previous/`;
717
881
  # any failure to find or read it is one line and an empty state. Its
718
- # `poll_state.json` is {"<branch>": {"run_id", "failures", "notified"}}; an
882
+ # `poll_state.json` is {"<branch>": {"run_id", "failures", "notified",
883
+ # "download_failures"}}, the last absent until a download fails; an
719
884
  # entry whose `run_id` is not the branch's newest `harness run` run is dropped,
720
885
  # so a new run restarts the count. `poll` writes the state to `poll_state/
721
886
  # current/` on every exit, `HARNESS_REMOTE_STOP` included, and the poller
@@ -730,9 +895,16 @@
730
895
  # download that fails for it is one line and waiting. For a `completed` run,
731
896
  # skipped, not waiting: a stopped branch, a branch absent on origin (its state
732
897
  # 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
735
- # integer `usage_resume_at`, and a run whose state entry says `notified`. Due
898
+ # that cannot answer is one line and proceeds), a run whose `run` job GitHub
899
+ # never started (one line; its `collect` job reports it), and anything but
900
+ # `status: paused` / `pause_reason: usage` with an integer `usage_resume_at`,
901
+ # and a run whose state entry says `notified`. A failed artifact lookup on a
902
+ # `completed` run counts as a failed download of its bundle, below. A listed
903
+ # bundle that cannot be downloaded is waiting, counted per run in `download_failures` and reset by a
904
+ # later successful download; at `HARNESS_POLL_MAX_DISPATCH_FAILURES`
905
+ # consecutive failures it sends exactly one `bundle_unreadable` push
906
+ # notification, with no comment and no label, the entry marked `notified`, and
907
+ # is no longer waiting. Due
736
908
  # (reset passed): re-dispatched under the same chain limit — a refusal is one
737
909
  # `failed` and not waiting; a success drops the branch's state entry. A
738
910
  # dispatch that fails counts one more failure for that run and is still
@@ -757,9 +929,12 @@
757
929
  # `pause-requested` lists the branch's runs and exits 0 when one whose
758
930
  # `displayTitle` is exactly `harness pause <branch>` has a `createdAt` at or
759
931
  # after <since_epoch> — AT, because `createdAt` has one-second resolution and
760
- # the caller takes <since_epoch> just before the query it will next start
761
- # from, so a pause created later in that same second is still seen; seeing one
762
- # twice is harmless, since the caller drops PAUSE once. `run-created-at` prints
932
+ # the caller passes a bound that starts at the job's own starting bound and
933
+ # afterwards lags each query by an overlap (`CONTROL_POLL_OVERLAP_SECS` in
934
+ # `autonomous-watcher.sh`), floored at that starting bound, so a pause created
935
+ # later in a second already queried is still seen; seeing one twice is
936
+ # harmless, since the caller drops PAUSE once. Exit 5 is "no pause"; exit 1 is
937
+ # a refused call and never one. `run-created-at` prints
763
938
  # `gh run view <run_id> --json createdAt` as an epoch second.
764
939
  #
765
940
  # `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
@@ -814,12 +989,17 @@
814
989
  # `engine` when the bundle names `task`, `user_review` or `docs`. A
815
990
  # `mirror` restore places no planning draft
816
991
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
817
- # older finished run carries one): a job that died before its upload.
992
+ # older finished run carries one): a job that died before its upload,
993
+ # or that GitHub never started (the detail says so).
818
994
  # `paused` / `killed`, `remote_run_id` / `remote_run_url` re-pointed at
819
- # THIS run, nothing restored — so a later sync with no newer run is case 1
995
+ # THIS run, and, for a run GitHub never started whose dispatch's comment
996
+ # records its engine, `engine` set to that engine; nothing restored — so
997
+ # a later sync with no newer run is case 1
820
998
  # 5. no bundle in any run and an empty `remote_run_id`: `failed`. Not
821
999
  # `paused`: with no bundle anywhere a pause resume has nothing to restore,
822
- # and re-dropping the artifact is the recovery
1000
+ # and re-dropping the artifact is the recovery, except a run whose job
1001
+ # GitHub never started and whose engine its dispatch's comment records,
1002
+ # which `sync` records as case 4 does, `paused (killed)`
823
1003
  #
824
1004
  # THE `killed` AND `expired` MAPPINGS. A finished run whose bundle still says
825
1005
  # `running` (a kill, a timeout with no chain left) syncs as `status: paused`,
@@ -828,7 +1008,16 @@
828
1008
  # never carries either, because `sync` derives them from the run and its
829
1009
  # artifact list, never from a bundle. Both are `paused` rather than `failed`
830
1010
  # because a `failed` record has no resume path, while the ledger on the branch
831
- # is intact and a resume continues from it.
1011
+ # is intact and a resume continues from it. A finished run with no bundle
1012
+ # whose `run` job GitHub never started (it ended `cancelled` or `failure` with
1013
+ # no step run) maps to the same states as any other run with no bundle:
1014
+ # `paused` / `killed` when an older run carries a bundle, else `failed`. Its
1015
+ # detail names GitHub's reason instead of "killed, cancelled or replaced".
1016
+ # A never-started run with no bundle anywhere whose engine its dispatch's
1017
+ # comment records maps to `paused` / `killed` too, so `resume` accepts it.
1018
+ # When that run was the branch's first, the branch has no ledger yet:
1019
+ # `restore` finds no previous bundle and the resumed job starts as the
1020
+ # branch's first. With no such comment it stays `failed`.
832
1021
  #
833
1022
  # THE WORKFLOW INPUT CONTRACT (the workflow template declares the same inputs):
834
1023
  #
@@ -893,37 +1082,55 @@
893
1082
  # listing that `trigger` and `review` make. Only `start` and `review` push, and
894
1083
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
895
1084
  # the prompt committed on `origin/<branch>`, through a working copy and a local
896
- # branch it removes before it returns; `review`'s are the round committed on
897
- # `origin/<branch>`, through the record's mirror or a copy and a local branch
898
- # it removes, the record's `status` / `engine`, the bundle download
899
- # directory `sync` uses, and `report`'s writes for the round. A user's chain-0 `dispatch --resume answer|pause`
1085
+ # branch it removes before it returns, and, after a push that did not land, a
1086
+ # fetch that force-writes that copy's `refs/remotes/origin/<branch>`
1087
+ # (`hr_push_landed`'s, to name the commit the remote moved to); `review`'s are
1088
+ # the round committed on `origin/<branch>`, through the record's mirror or a
1089
+ # copy and a local branch it removes, and, after a push that did not land, the
1090
+ # same `hr_push_landed` fetch force-writing `refs/remotes/origin/<branch>` in
1091
+ # that mirror or copy, the record's `status` / `engine`, the bundle download
1092
+ # directory `sync` uses, and `report`'s writes for the round. Its settledness
1093
+ # read, when the newest run's job GitHub never started, also fetches origin's
1094
+ # <branch>, force-writing `refs/remotes/origin/<branch>` in the root checkout:
1095
+ # for a local `/autonomous-sdlc-harness:branch-user-review`, the main checkout. A user's chain-0 `dispatch --resume answer|pause`
900
1096
  # writes the record's `status`, `resumed_at` and `resume_kind` in one write
901
1097
  # when the main checkout's registry file exists and holds a record with
902
1098
  # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
903
1099
  # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
904
1100
  # for a dispatch event a block in the step summary. `report` writes its comment
905
1101
  # file under `RUNNER_TEMP` (removed), one comment and the state labels on the
906
- # issue and the pull request, and nothing local. `deliver` writes its body and
1102
+ # issue and the pull request, and nothing local. `open` writes its body and
1103
+ # comment files under `RUNNER_TEMP` (removed), at most one pull request, its
1104
+ # `running` label and one comment on the issue. `deliver` writes its body and
907
1105
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
908
1106
  # comment and the state labels. `control` writes its reply file and its
909
1107
  # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
910
- # what the child verb it runs writes. `collect` writes its round file and its
911
- # settledness directory under `RUNNER_TEMP` (removed), at most one comment on
912
- # the pull request, plus what its `review` child writes. `stop`, `continue` and `poll` also make
1108
+ # what the child verb it runs writes, and the fetch of origin's <branch> that
1109
+ # force-writes `refs/remotes/origin/<branch>` in its checkout. `collect` writes its round file and its
1110
+ # settledness directory under `RUNNER_TEMP` (removed), at most one comment, on
1111
+ # the pull request, or on the issue for a run whose job never started, and for
1112
+ # that event `report`'s state labels on both items and one push notification,
1113
+ # the fetch of origin's <branch> its settledness read makes for such a run,
1114
+ # force-writing `refs/remotes/origin/<branch>`, plus what its `review` child
1115
+ # writes. `stop`, `continue` and `poll` also make
913
1116
  # `report`'s writes for each event they report. Every other verb's only writes are the
914
1117
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
915
1118
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
916
1119
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
917
- # `hr_remote_bundle_restore` performs in the record's `worktree`; for
1120
+ # `hr_remote_bundle_restore` performs in the record's `worktree`, and, when
1121
+ # the newest run's job GitHub never started, a fetch force-writing
1122
+ # `refs/remotes/origin/<branch>` in the main checkout; for
918
1123
  # `restore`, that download directory, the job restore (the planning drafts
919
1124
  # among it), `answer_<n>.md` and the
920
1125
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
921
1126
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
922
1127
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
923
- # and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
924
- # <dir> only. `pause-requested`,
925
- # `run-created-at`, `list` and `status` write nothing; a no-record `status`
926
- # downloads into a temporary directory it removes on exit.
1128
+ # and `current/`. For `fetch`, <out_dir> and, when the newest run's job GitHub
1129
+ # never started, a fetch force-writing `refs/remotes/origin/<branch>` in the
1130
+ # root checkout. For `discard`, the removal of <dir> only. `pause-requested`,
1131
+ # `run-created-at` and `list` write nothing, and `status` writes nothing but,
1132
+ # with no record, that same fetch; a no-record `status` downloads into a
1133
+ # temporary directory it removes on exit.
927
1134
  #
928
1135
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
929
1136
  # rename there is an edit here, byte for byte:
@@ -936,6 +1143,7 @@
936
1143
  # DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
937
1144
  # LEGACY_TRIGGER_LABEL mirrors LEGACY_TRIGGER_LABEL
938
1145
  # HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
1146
+ # HARNESS_RUN_ACTORS mirrors RUN_ACTORS_VARIABLE
939
1147
  # TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
940
1148
  # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
941
1149
  # COMMAND_HANDLE mirrors COMMAND_HANDLE
@@ -1112,7 +1320,9 @@
1112
1320
  # the job's reads: a `run list` answer whose run has `displayTitle` `harness
1113
1321
  # pause feat_x` and `createdAt` `2026-01-01T00:00:10Z` (epoch 1767225610):
1114
1322
  # pause-requested bash scripts/remote-run.sh pause-requested feat_x 1767225600
1115
- # -> 0; with 1767225620 -> 1; a stub failing `run list` -> 3
1323
+ # -> 0; with 1767225620 -> 5; a stub failing `run list` -> 3;
1324
+ # bash scripts/remote-run.sh pause-requested feat_x (no
1325
+ # <since_epoch>) -> 1
1116
1326
  # run-created-at a stub answering `run view 42 --json createdAt` with
1117
1327
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
1118
1328
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
@@ -1123,13 +1333,18 @@
1123
1333
  # "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
1124
1334
  # answering `api repos/o/r/collaborators/alice/permission` with
1125
1335
  # {"permission":"write"}; export GITHUB_EVENT_NAME=issues GITHUB_EVENT_PATH=e.json
1126
- # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0:
1336
+ # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0 HARNESS_RUN_ACTORS='*':
1127
1337
  # trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
1128
1338
  # the prompt commit, "$s.log" gains `workflow run harness-run.yml
1129
1339
  # --ref add_comments ...`, `issue comment 7 ...` naming the branch,
1130
1340
  # then `issue edit 7 ... --remove-label sdlc-harness`
1131
1341
  # read the permission answer {"permission":"read"} -> 2, no `workflow
1132
1342
  # run`, one comment naming write access, the label removed
1343
+ # not listed HARNESS_RUN_ACTORS=bob -> 2, no `workflow run`, one comment
1344
+ # naming HARNESS_RUN_ACTORS, the label removed
1345
+ # unset HARNESS_RUN_ACTORS= and e.json gaining
1346
+ # "repository":{"owner":{"login":"alice","type":"User"}} -> 0, as
1347
+ # `trigger`; the owner `bob` instead -> 2, the comment naming @bob
1133
1348
  # ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
1134
1349
  # dispatch GITHUB_EVENT_NAME=repository_dispatch GITHUB_RUN_ID=9
1135
1350
  # GITHUB_STEP_SUMMARY=/tmp/s, e.json {"action":<TRIGGER_DISPATCH_EVENT_TYPE>,
@@ -1146,15 +1361,45 @@
1146
1361
  # naming `@sdlc-harness resume`, then `api --method POST
1147
1362
  # repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
1148
1363
  # forge off `"forge": "none"`, then the same -> 0, one line, "$s.log" unchanged
1364
+ # progress a task ledger at sdlc-harness/flow_progress/feat_x_progress.md in
1365
+ # the checkout and the stub answering `pr list` with [{"number":12,
1366
+ # "isCrossRepository":false}]: bash scripts/remote-run.sh report
1367
+ # progress feat_x -> 0; "$s.log" gains `api --paginate
1368
+ # repos/o/r/issues/12/comments ...`, then one POST on 12 ending in
1369
+ # `event=progress branch=feat_x -->`; with that comment listed, no
1370
+ # write; with the ledger changed, one `PATCH repos/o/r/issues/comments/<id>`
1149
1371
  #
1150
1372
  # deliver needs report's setup, a stub answering the create with
1151
1373
  # https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
1152
1374
  # carries schema "1" and status completed:
1153
1375
  # deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
1154
- # a `--draft` create with `--base main --head feat_x`, then one
1155
- # comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
1156
- # 7 and 12
1376
+ # a `--draft` create with `--base main --head feat_x`, then `pr
1377
+ # ready 12` with the job's token, then one comment on 12 saying
1378
+ # it is now marked ready, then one on issue 7 naming #12 and
1379
+ # /pull/12, then `sdlc-harness: done` on 7 and 12
1380
+ # reused the stub answering `pr list` with [{"number":12,
1381
+ # "isCrossRepository":false,"isDraft":false}] -> 0, no create, no
1382
+ # `pr ready`, the pull request's comment saying it is not a draft
1383
+ # refused the stub failing `pr ready` -> 0, one `::warning::` line, both
1384
+ # comments posted, the pull request's saying the flip was refused
1157
1385
  # not done status parked, or an empty <b> -> 0, one line, "$s.log" unchanged
1386
+ # threads <b>'s engine user_review; on feat_x a `feat_x_review.md` ending in
1387
+ # the marker `comments=101,103`, a `feat_x_fix_plan.md` whose
1388
+ # `1. [x] **Finding 1**` lands in its own commit and whose `## Out
1389
+ # of scope / verified-OK` bullet ends ` **Review comments:** 103`,
1390
+ # and `feat_x_fix_plan/finding_1.md` carrying `**Review comments:**
1391
+ # 101`; a stub answering `api graphql` with one open thread per id
1392
+ # -> 0; "$s.log" gains a reply on comment 101 naming that commit,
1393
+ # then one `resolveReviewThread`, then a reply on 103 quoting the
1394
+ # bullet and no resolve; no dismissal call
1395
+ #
1396
+ # open needs deliver's setup without the bundle:
1397
+ # open bash scripts/remote-run.sh open feat_x -> 0; "$s.log" gains a
1398
+ # `--draft` create with `--base main --head feat_x`, then
1399
+ # `sdlc-harness: running` on 12, then one comment on issue 7
1400
+ # naming /pull/12 and ending in `event=opened branch=feat_x`
1401
+ # reused the stub answering `pr list` with [{"number":12,
1402
+ # "isCrossRepository":false}] -> 0, one line, no create, no comment
1158
1403
  #
1159
1404
  # collect needs deliver's setup, a story index on feat_x, a stub answering
1160
1405
  # `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
@@ -1242,6 +1487,11 @@ RUN_JOB_NAME='run'
1242
1487
  # How far before the previous round's `collected_at` `round_collect` lists
1243
1488
  # from, absorbing runner-clock skew; the id check drops what it re-lists.
1244
1489
  ROUND_OVERLAP_SECS=300
1490
+ # How far before a run's `createdAt` `forge_dispatch_engine_var` still trusts
1491
+ # a dispatcher's comment: the comment is posted after the dispatch request and
1492
+ # GitHub creates the run asynchronously, so either may carry the earlier
1493
+ # timestamp. Small enough that an older dispatch's comment falls outside it.
1494
+ DISPATCH_MARKER_SLACK_SECS=120
1245
1495
 
1246
1496
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
1247
1497
  # maximum payload for inputs is 65,535 characters."
@@ -1253,8 +1503,9 @@ EXIT_USAGE=1
1253
1503
  EXIT_REFUSED=2
1254
1504
  EXIT_GH=3
1255
1505
  EXIT_PLACEMENT=4
1256
- # pause-requested only: the read succeeded and found no pause.
1257
- EXIT_NO_PAUSE=1
1506
+ # pause-requested only: the read succeeded and found no pause; distinct from
1507
+ # EXIT_USAGE so a caller never reads a refused call as no pause.
1508
+ EXIT_NO_PAUSE=5
1258
1509
 
1259
1510
  usage() {
1260
1511
  echo "remote-run.sh: $1" >&2
@@ -1277,6 +1528,7 @@ usage() {
1277
1528
  echo " remote-run.sh list [--repo <root>]" >&2
1278
1529
  echo " remote-run.sh discard <dir> [--repo <root>]" >&2
1279
1530
  echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
1531
+ echo " remote-run.sh open <branch> [--repo <root>]" >&2
1280
1532
  echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1281
1533
  echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1282
1534
  echo " remote-run.sh control [--repo <root>]" >&2
@@ -1349,7 +1601,7 @@ verb=""
1349
1601
  verb="$1"
1350
1602
  shift
1351
1603
  case "$verb" in
1352
- dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|deliver|collect|control) ;;
1604
+ dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|open|deliver|collect|control) ;;
1353
1605
  *) usage "unknown verb '$verb'" ;;
1354
1606
  esac
1355
1607
 
@@ -1574,10 +1826,11 @@ fi
1574
1826
  # ---------------------------------------------------------------------------
1575
1827
 
1576
1828
  # setup_fail <message> — a configuration problem: exit 1, except for save,
1577
- # report, deliver and collect, which never fail the step that calls them.
1829
+ # report, open, deliver and collect, which never fail the step that calls them.
1578
1830
  setup_fail() {
1579
1831
  echo "remote-run.sh: $1" >&2
1580
- [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != deliver ] && [ "$verb" != collect ] || exit "$EXIT_OK"
1832
+ [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != open ] && [ "$verb" != deliver ] \
1833
+ && [ "$verb" != collect ] || exit "$EXIT_OK"
1581
1834
  exit "$EXIT_USAGE"
1582
1835
  }
1583
1836
 
@@ -1585,8 +1838,8 @@ if [ -n "$repo_arg" ]; then
1585
1838
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
1586
1839
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
1587
1840
  || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
1588
- || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
1589
- || [ "$verb" = control ]; then
1841
+ || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = open ] || [ "$verb" = deliver ] \
1842
+ || [ "$verb" = collect ] || [ "$verb" = control ]; then
1590
1843
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
1591
1844
  else
1592
1845
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -1605,7 +1858,7 @@ case "$verb" in
1605
1858
  trigger|control)
1606
1859
  # Gates itself, after reading the event, so a refusal can still be commented.
1607
1860
  ;;
1608
- report|deliver|collect)
1861
+ report|open|deliver|collect)
1609
1862
  # Gates itself (`forge_on`) and exits 0 on every outcome.
1610
1863
  ;;
1611
1864
  status|sync)
@@ -1885,6 +2138,54 @@ expired_line() {
1885
2138
  printf '%s' "the state bundle of run $1 expired on $BUNDLE_EXPIRES_AT: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task"
1886
2139
  }
1887
2140
 
2141
+ # run_not_started_var <run_id> — whether GitHub never started the run's
2142
+ # RUN_JOB_NAME job: 0 when that job's `conclusion` is `cancelled` or `failure`
2143
+ # and its `steps` array is absent or empty, with NOT_STARTED_REASON set; 1 when
2144
+ # the job started or none of that name is listed; 2 with GH_ERR set when the
2145
+ # jobs lookup failed or was not the expected JSON. Never exits. The reason is
2146
+ # the first message of the job's check-run annotations (a job's `id` is its
2147
+ # check run's id); that lookup is best-effort, and a failed or empty one falls
2148
+ # back to a line naming the conclusion. Replaces GH_OUT.
2149
+ NOT_STARTED_REASON=""
2150
+ run_not_started_var() {
2151
+ local answer job_id conclusion message
2152
+ NOT_STARTED_REASON=""
2153
+ gh_call api "repos/{owner}/{repo}/actions/runs/$1/jobs" || return 2
2154
+ answer=$(printf '%s' "$GH_OUT" | jq -r --arg n "$RUN_JOB_NAME" '
2155
+ if type == "object" and (.jobs | type) == "array" then
2156
+ ([.jobs[] | select(type == "object" and .name == $n)] | first) as $j
2157
+ | if $j == null then "absent"
2158
+ elif ($j.conclusion == "cancelled" or $j.conclusion == "failure")
2159
+ and (($j.steps // []) | if type == "array" then length else 1 end) == 0
2160
+ then "not_started\t" + ($j.id | tostring) + "\t" + $j.conclusion
2161
+ else "started" end
2162
+ else "invalid" end' 2>/dev/null)
2163
+ case "$answer" in
2164
+ absent|started) return 1 ;;
2165
+ not_started$'\t'*) ;;
2166
+ *) GH_ERR="its job list is not the expected JSON"; return 2 ;;
2167
+ esac
2168
+ answer="${answer#*$'\t'}"
2169
+ job_id="${answer%%$'\t'*}"
2170
+ conclusion="${answer#*$'\t'}"
2171
+ message=""
2172
+ case "$job_id" in
2173
+ ''|*[!0-9]*) ;;
2174
+ *)
2175
+ if gh_call api "repos/{owner}/{repo}/check-runs/$job_id/annotations"; then
2176
+ message=$(printf '%s' "$GH_OUT" | jq -r '
2177
+ [.[]? | .message? | select(type == "string" and length > 0) | split("\n")[0]] | first // ""' 2>/dev/null) || message=""
2178
+ fi
2179
+ ;;
2180
+ esac
2181
+ if [ -n "$message" ]; then
2182
+ NOT_STARTED_REASON="$message"
2183
+ else
2184
+ NOT_STARTED_REASON="its \`$RUN_JOB_NAME\` job ended \`$conclusion\` with no step run"
2185
+ fi
2186
+ return 0
2187
+ }
2188
+
1888
2189
  set_or_fail() {
1889
2190
  hr_registry_set "$registry" "$branch" "$1" "$2" || {
1890
2191
  echo "remote-run.sh: writing $1 of $branch to '$registry' failed" >&2
@@ -2003,10 +2304,17 @@ sync_expired() {
2003
2304
  # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
2004
2305
  # existing for case 4. <finished> 1 reads the newest run as finished whatever
2005
2306
  # its `status`: `branch_settled_var` passes it once that run's `run` job has
2006
- # completed. Exits 3 when gh fails, 2 for an unrecognised bundle.
2307
+ # completed. RS_RUN_CREATED_AT is the newest run's `createdAt` (ISO 8601).
2308
+ # RS_NOT_STARTED is 1 only in cases 4 and 5, when `run_not_started_var` finds
2309
+ # GitHub never started that run's RUN_JOB_NAME job; RS_ENGINE is then
2310
+ # `forge_dispatch_engine_var`'s answer, and case 5 with one is `paused` /
2311
+ # `killed`. Replaces GH_OUT in cases 4 and 5. Exits 3 when gh fails, 2
2312
+ # for an unrecognised bundle.
2007
2313
  RS_RUNS=""
2008
2314
  RS_RUN_ID=""
2009
2315
  RS_RUN_URL=""
2316
+ RS_RUN_CREATED_AT=""
2317
+ RS_NOT_STARTED=0
2010
2318
  RS_GH_STATUS=""
2011
2319
  RS_STATE=""
2012
2320
  RS_PAUSE_REASON=""
@@ -2020,7 +2328,8 @@ remote_state() {
2020
2328
  local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
2021
2329
  RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
2022
2330
  RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
2023
- RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
2331
+ RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""; RS_RUN_CREATED_AT=""
2332
+ RS_NOT_STARTED=0
2024
2333
  RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
2025
2334
  newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
2026
2335
  if [ -z "$newest" ]; then
@@ -2030,6 +2339,7 @@ remote_state() {
2030
2339
  RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
2031
2340
  RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
2032
2341
  RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
2342
+ RS_RUN_CREATED_AT=$(printf '%s' "$newest" | jq -r '.createdAt // ""')
2033
2343
  if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
2034
2344
  RS_STATE=running
2035
2345
  return 0
@@ -2097,12 +2407,32 @@ remote_state() {
2097
2407
  RS_STATE=paused
2098
2408
  RS_PAUSE_REASON=killed
2099
2409
  RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
2100
- return 0
2410
+ else
2411
+ # Case 5 — no bundle in any run.
2412
+ RS_STATE=failed
2413
+ RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
2101
2414
  fi
2102
2415
 
2103
- # Case 5 — no bundle in any run.
2104
- RS_STATE=failed
2105
- RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
2416
+ # Cases 4 and 5 — a job GitHub never started keeps its case's state; only
2417
+ # the detail changes. A failed jobs lookup changes nothing but says so.
2418
+ run_not_started_var "$RS_RUN_ID"
2419
+ case $? in
2420
+ 0)
2421
+ RS_NOT_STARTED=1
2422
+ RS_DETAIL="GitHub did not start the job of run $RS_RUN_ID ($NOT_STARTED_REASON): $RS_RUN_URL"
2423
+ # No bundle names the engine, so the dispatch's own comment does; with
2424
+ # one, case 5 becomes resumable from the ledger as case 4 is.
2425
+ if [ -z "$RS_ENGINE" ]; then
2426
+ forge_dispatch_engine_var "$branch" "$RS_RUN_CREATED_AT" || :
2427
+ RS_ENGINE="$FORGE_DISPATCH_ENGINE"
2428
+ if [ "$RS_STATE" = failed ] && [ -n "$RS_ENGINE" ]; then
2429
+ RS_STATE=paused
2430
+ RS_PAUSE_REASON=killed
2431
+ fi
2432
+ fi
2433
+ ;;
2434
+ 2) echo "remote-run.sh: could not tell whether GitHub started the job of run $RS_RUN_ID: $GH_ERR" >&2 ;;
2435
+ esac
2106
2436
  return 0
2107
2437
  }
2108
2438
 
@@ -2232,10 +2562,16 @@ verb_sync() {
2232
2562
  return 0
2233
2563
  fi
2234
2564
 
2235
- # Case 4 — a newer run with no bundle, while some bundle exists.
2565
+ # Case 4 — a newer run with no bundle, while some bundle exists; also a run
2566
+ # GitHub never started whose engine its dispatch's comment records.
2236
2567
  if [ "$RS_STATE" = paused ]; then
2237
- set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2238
- remote_detail "$RS_DETAIL" remote_synced_at "$now"
2568
+ if [ "$RS_NOT_STARTED" = 1 ] && valid_engine "$RS_ENGINE"; then
2569
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2570
+ remote_detail "$RS_DETAIL" remote_synced_at "$now" engine "$RS_ENGINE"
2571
+ else
2572
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2573
+ remote_detail "$RS_DETAIL" remote_synced_at "$now"
2574
+ fi
2239
2575
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
2240
2576
  return 0
2241
2577
  fi
@@ -2489,12 +2825,9 @@ verb_save() {
2489
2825
  return 0
2490
2826
  }
2491
2827
 
2492
- # notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
2493
- # then `forge_report` of the same event; never fails. Every call site supplies
2494
- # both texts: <detail> is the push notification's, slash commands included;
2495
- # <forge_note> is the comment's, naming no slash command and no shell command,
2496
- # and states only what happened, since `forge_report` adds the next action.
2497
- notify() {
2828
+ # notify_push <event> <branch> <detail> — the push notification alone: it
2829
+ # posts no comment and sets no label. Never fails.
2830
+ notify_push() {
2498
2831
  if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
2499
2832
  HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
2500
2833
  export HARNESS_REPO_SLUG
@@ -2502,6 +2835,15 @@ notify() {
2502
2835
  bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
2503
2836
  || echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
2504
2837
  echo "remote-run.sh: notified $1 for $2: $3"
2838
+ }
2839
+
2840
+ # notify <event> <branch> <detail> <forge_note> — notify_push, then
2841
+ # `forge_report` of the same event; never fails. Every call site supplies
2842
+ # both texts: <detail> is the push notification's, slash commands included;
2843
+ # <forge_note> is the comment's, naming no slash command and no shell command,
2844
+ # and states only what happened, since `forge_report` adds the next action.
2845
+ notify() {
2846
+ notify_push "$1" "$2" "$3"
2505
2847
  # stdin closed: `poll` calls this inside a loop reading its run list.
2506
2848
  forge_report "$1" "$2" "$4" </dev/null
2507
2849
  }
@@ -2698,7 +3040,10 @@ verb_continue() {
2698
3040
  }
2699
3041
 
2700
3042
  # POLL_STATE — the poller state carried between ticks, one object keyed by
2701
- # branch: {"<branch>": {"run_id", "failures", "notified"}}, every value a string.
3043
+ # branch: {"<branch>": {"run_id", "failures", "notified", "download_failures"}},
3044
+ # every value a string. `failures` counts failed re-dispatches only;
3045
+ # `download_failures`, absent until a download fails, counts consecutive failed
3046
+ # downloads of the run's listed bundle.
2702
3047
  POLL_STATE='{}'
2703
3048
  POLL_STATE_FILE_NAME='poll_state.json'
2704
3049
 
@@ -2706,9 +3051,21 @@ poll_state_get() {
2706
3051
  printf '%s' "$POLL_STATE" | jq -r --arg b "$1" --arg f "$2" '.[$b][$f] // "" | tostring'
2707
3052
  }
2708
3053
 
3054
+ # poll_state_put <branch> <run_id> <failures> <notified> — keeps the entry's
3055
+ # `download_failures` when its `run_id` is the same.
2709
3056
  poll_state_put() {
2710
- POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg f "$3" --arg n "$4" \
2711
- '.[$b] = {run_id: $r, failures: $f, notified: $n}')
3057
+ POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg f "$3" --arg n "$4" '
3058
+ (.[$b] // {}) as $o
3059
+ | .[$b] = {run_id: $r, failures: $f, notified: $n}
3060
+ + (if $o.run_id == $r and ($o | has("download_failures")) then {download_failures: $o.download_failures} else {} end)')
3061
+ }
3062
+
3063
+ # poll_state_downloads_put <branch> <run_id> <count> — sets `download_failures`,
3064
+ # on a fresh entry when the stored `run_id` differs.
3065
+ poll_state_downloads_put() {
3066
+ POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg c "$3" '
3067
+ (.[$b] // {}) as $o
3068
+ | .[$b] = (if $o.run_id == $r then $o else {run_id: $r, failures: "", notified: ""} end) + {download_failures: $c}')
2712
3069
  }
2713
3070
 
2714
3071
  poll_state_drop() {
@@ -2827,7 +3184,7 @@ poll_usage_paused() {
2827
3184
  # With may_dispatch 0 nothing is sent and nothing notified: a due run that
2828
3185
  # would be dispatched counts as waiting, one that would be refused does not.
2829
3186
  poll_branch() {
2830
- local id="$1" state="$2" may_dispatch="$3" at engine_value now failures
3187
+ local id="$1" state="$2" may_dispatch="$3" at engine_value now failures downloads listed
2831
3188
  if [ "$may_dispatch" -eq 1 ] && [ -n "$(poll_state_get "$branch" run_id)" ] \
2832
3189
  && [ "$(poll_state_get "$branch" run_id)" != "$id" ]; then
2833
3190
  poll_state_drop "$branch"
@@ -2862,10 +3219,40 @@ poll_branch() {
2862
3219
  echo "remote-run.sh: poll: $branch's run $id was already reported as not re-dispatchable; skipped"
2863
3220
  return 1
2864
3221
  fi
2865
- if ! poll_fetch "$id"; then
2866
- echo "remote-run.sh: poll: $branch skipped"
3222
+ bundle_listed "$id"
3223
+ listed=$?
3224
+ if [ "$listed" -eq 1 ]; then
3225
+ if run_not_started_var "$id"; then
3226
+ echo "remote-run.sh: poll: run $id of $branch never started ($NOT_STARTED_REASON); its collect job reports it; not waiting"
3227
+ else
3228
+ echo "remote-run.sh: poll: $branch skipped"
3229
+ fi
3230
+ return 1
3231
+ fi
3232
+ [ "$listed" -ne 2 ] || echo "remote-run.sh: poll: reading the artifacts of run $id ($branch) failed ($GH_ERR)"
3233
+ downloads=""
3234
+ [ "$(poll_state_get "$branch" run_id)" != "$id" ] || downloads=$(poll_state_get "$branch" download_failures)
3235
+ if [ "$listed" -eq 2 ] || ! poll_fetch "$id"; then
3236
+ case "$downloads" in
3237
+ ''|*[!0-9]*) downloads=0 ;;
3238
+ esac
3239
+ downloads=$((10#$downloads + 1))
3240
+ if [ "$downloads" -lt "$((10#$POLL_MAX_FAILURES))" ]; then
3241
+ [ "$may_dispatch" -eq 0 ] || poll_state_downloads_put "$branch" "$id" "$downloads"
3242
+ echo "remote-run.sh: poll: the bundle of run $id ($branch) could not be downloaded, attempt $downloads of $POLL_MAX_FAILURES; still waiting"
3243
+ return 0
3244
+ fi
3245
+ if [ "$may_dispatch" -eq 1 ]; then
3246
+ poll_state_put "$branch" "$id" "$(poll_state_get "$branch" failures)" 1
3247
+ # Push only, under a word outside autonomous-notify.sh's events: the run's
3248
+ # state is unknown, so neither a `failed` report nor a label is true of it.
3249
+ notify_push bundle_unreadable "$branch" "The resume poller could not download the state bundle of run $id after $downloads attempts ($GH_ERR); it no longer watches $branch. Its state is unknown: check the run, then run $RESUME_HINT $branch if it is paused; $(hr_github_resume_route "$branch" "")."
3250
+ fi
2867
3251
  return 1
2868
3252
  fi
3253
+ if [ -n "$downloads" ] && [ "$may_dispatch" -eq 1 ]; then
3254
+ poll_state_downloads_put "$branch" "$id" ""
3255
+ fi
2869
3256
  poll_usage_paused || return 1
2870
3257
  at=$(hr_remote_status_get "$POLL_STATUS_FILE" usage_resume_at) || at=""
2871
3258
  case "$at" in
@@ -3099,8 +3486,12 @@ verb_start() {
3099
3486
  status=0
3100
3487
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
3101
3488
  [ "$status" -ne 1 ] || placement_fail "committing '$rel'"
3102
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3103
- || placement_fail "pushing $branch (origin/$branch is not HEAD)"
3489
+ status=0
3490
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 || status=$?
3491
+ [ "$status" -ne 2 ] \
3492
+ || placement_fail "pushing $branch: origin/$branch moved to $HR_PUSH_REMOTE_TIP, which this branch does not have (something else pushed)"
3493
+ [ "$status" -eq 0 ] \
3494
+ || placement_fail "pushing $branch: the remote refused the push (push-branch.sh's lines in this job's log name why)"
3104
3495
  start_remove_copy
3105
3496
  trap - EXIT
3106
3497
 
@@ -3253,8 +3644,12 @@ NAMES
3253
3644
  status=0
3254
3645
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
3255
3646
  [ "$status" -eq 0 ] || review_fail "committing '$rel'"
3256
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3257
- || review_fail "pushing $branch (origin/$branch is not HEAD)"
3647
+ status=0
3648
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 || status=$?
3649
+ [ "$status" -ne 2 ] \
3650
+ || review_fail "pushing $branch: origin/$branch moved to $HR_PUSH_REMOTE_TIP, which this branch does not have (something else pushed)"
3651
+ [ "$status" -eq 0 ] \
3652
+ || review_fail "pushing $branch: the remote refused the push (push-branch.sh's lines in this job's log name why)"
3258
3653
  pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
3259
3654
  if [ "$use_mirror" -eq 0 ]; then
3260
3655
  start_remove_copy
@@ -3472,13 +3867,73 @@ trigger_bot_listed() {
3472
3867
  return 1
3473
3868
  }
3474
3869
 
3870
+ # run_actor_listed <login> — 0 when HARNESS_RUN_ACTORS admits <login>: split on
3871
+ # `,`, each entry trimmed, empty entries dropped, matched case-insensitively,
3872
+ # `*` admitting everyone, an empty <login> admitted by `*` alone. A list with no
3873
+ # entries is unset, and admits only `.repository.owner.login` of the event file
3874
+ # when `.repository.owner.type` is `User`; any other owner, or an event file or
3875
+ # field that cannot be read, admits nobody. Otherwise 1, with RUN_ACTORS_WHY
3876
+ # holding one sentence. Lowercased with `tr`, never a parameter expansion:
3877
+ # bash 3.2 has no case-changing one.
3878
+ RUN_ACTORS_WHY=""
3879
+ run_actor_listed() {
3880
+ local login="$1" want rest="${HARNESS_RUN_ACTORS-}," entry listed="" owner="" owner_type=""
3881
+ RUN_ACTORS_WHY=""
3882
+ want=$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')
3883
+ while [ -n "$rest" ]; do
3884
+ entry=${rest%%,*}
3885
+ rest=${rest#*,}
3886
+ entry=${entry#"${entry%%[![:space:]]*}"}
3887
+ entry=${entry%"${entry##*[![:space:]]}"}
3888
+ [ -n "$entry" ] || continue
3889
+ listed=1
3890
+ [ "$entry" = '*' ] && return 0
3891
+ [ -n "$want" ] && [ "$(printf '%s' "$entry" | tr '[:upper:]' '[:lower:]')" = "$want" ] && return 0
3892
+ done
3893
+ if [ -n "$listed" ]; then
3894
+ 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."
3895
+ return 1
3896
+ fi
3897
+ if [ -n "${GITHUB_EVENT_PATH-}" ] && [ -r "$GITHUB_EVENT_PATH" ]; then
3898
+ event_field '.repository.owner.type // ""' && owner_type="$EVENT_VALUE"
3899
+ event_field '.repository.owner.login // ""' && owner="$EVENT_VALUE"
3900
+ fi
3901
+ if [ "$owner_type" = User ] && [ -n "$owner" ]; then
3902
+ [ -n "$want" ] && [ "$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')" = "$want" ] && return 0
3903
+ RUN_ACTORS_WHY="@$login is not the repository owner, and the repository variable HARNESS_RUN_ACTORS is unset, which admits the owner, @$owner, alone."
3904
+ return 1
3905
+ fi
3906
+ 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."
3907
+ return 1
3908
+ }
3909
+
3910
+ # rerun_actor_listed — 0 unless this job is a re-run (GITHUB_RUN_ATTEMPT above
3911
+ # 1) whose GITHUB_TRIGGERING_ACTOR is neither `github-actions[bot]` nor
3912
+ # admitted by run_actor_listed; then 1, with RUN_ACTORS_WHY naming the
3913
+ # re-runner. A re-run replays the original event, whose sender is the person
3914
+ # who first acted, so authorise_actor alone would check the wrong account.
3915
+ rerun_actor_listed() {
3916
+ local attempt="${GITHUB_RUN_ATTEMPT-}" who="${GITHUB_TRIGGERING_ACTOR-}"
3917
+ RUN_ACTORS_WHY=""
3918
+ case "$attempt" in ''|*[!0-9]*) return 0 ;; esac
3919
+ [ "$attempt" -gt 1 ] || return 0
3920
+ [ "$who" != 'github-actions[bot]' ] || return 0
3921
+ run_actor_listed "$who" && return 0
3922
+ 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"
3923
+ return 1
3924
+ }
3925
+
3475
3926
  # authorise_actor <login> <type> — the one actor check, shared by `trigger` and
3476
3927
  # `control`: 0 when authorised. Otherwise AUTH_WHY holds one sentence and the
3477
3928
  # status names the arm: 1 `ghost`, empty or not a login shape; 2 a non-`User`
3478
3929
  # not listed in HARNESS_TRIGGER_ALLOWED_BOTS, decided with no permission call,
3479
- # because the permission API answers `none` or 404 for a bot; 3 a `User` whose
3480
- # permission is not `admin` or `write` (AUTH_PERMISSION holds it); 4 that
3481
- # permission call failed (GH_ERR holds why). Prints nothing and never posts.
3930
+ # because the permission API answers `none` or 404 for a bot, and never
3931
+ # consulting the run-actor list; 3 a `User` whose permission is not `admin` or
3932
+ # `write` (AUTH_PERMISSION holds it); 4 that permission call failed (GH_ERR
3933
+ # holds why); 5 a writer `run_actor_listed` refuses. The list is checked after
3934
+ # the permission call so a non-writer's refusal keeps naming write access, and
3935
+ # 5 is kept for a writer the maintainer has not named. Prints nothing and
3936
+ # never posts.
3482
3937
  AUTH_WHY=""
3483
3938
  AUTH_PERMISSION=""
3484
3939
  authorise_actor() {
@@ -3502,7 +3957,10 @@ authorise_actor() {
3502
3957
  fi
3503
3958
  AUTH_PERMISSION=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || AUTH_PERMISSION=""
3504
3959
  case "$AUTH_PERMISSION" in
3505
- admin|write) return 0 ;;
3960
+ admin|write)
3961
+ run_actor_listed "$login" && return 0
3962
+ AUTH_WHY="$RUN_ACTORS_WHY"
3963
+ return 5 ;;
3506
3964
  esac
3507
3965
  AUTH_WHY="GitHub reports the permission of @$login as ${AUTH_PERMISSION:-nothing}, not write or admin."
3508
3966
  return 3
@@ -3602,7 +4060,9 @@ verb_trigger() {
3602
4060
  { event_field '.action // ""' && action="$EVENT_VALUE" \
3603
4061
  && event_field '.client_payload.title // ""' && title="$EVENT_VALUE" \
3604
4062
  && event_field '.client_payload.body // ""' && body="$EVENT_VALUE" \
3605
- && event_field '.client_payload.source // ""' && source="$EVENT_VALUE"; } || {
4063
+ && event_field '.client_payload.source // ""' && source="$EVENT_VALUE" \
4064
+ && event_field '.sender.login // ""' && login="$EVENT_VALUE" \
4065
+ && event_field '.sender.type // ""' && sender_type="$EVENT_VALUE"; } || {
3606
4066
  echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
3607
4067
  exit "$EXIT_USAGE"
3608
4068
  }
@@ -3638,6 +4098,26 @@ verb_trigger() {
3638
4098
  "Set both keys on the default branch, then $retry_then."
3639
4099
  fi
3640
4100
 
4101
+ # A re-run replays the event's sender; the re-runner is held to the list.
4102
+ if ! rerun_actor_listed; then
4103
+ trigger_refuse "$RUN_ACTORS_WHY" \
4104
+ "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can $retry_then."
4105
+ fi
4106
+
4107
+ # A dispatch's `User` sender is held to the allow-list with no permission
4108
+ # call; a sender with no type fails closed, since an empty login is admitted
4109
+ # by `*` alone; any other sender type is the token's, and passes.
4110
+ if [ "$trigger_source" = dispatch ]; then
4111
+ if [ -z "$sender_type" ] && ! run_actor_listed ""; then
4112
+ trigger_refuse "the dispatch's sender carries no account type, so it cannot be checked against the allow-list." \
4113
+ "Set the repository variable \`HARNESS_RUN_ACTORS\` to \`*\` to admit every token holder, then $retry_then."
4114
+ fi
4115
+ if [ "$sender_type" = User ] && ! run_actor_listed "$login"; then
4116
+ trigger_refuse "$RUN_ACTORS_WHY" \
4117
+ "Add \`$login\` to the comma-separated repository variable \`HARNESS_RUN_ACTORS\`, or set it to \`*\` to admit every writer, then $retry_then."
4118
+ fi
4119
+ fi
4120
+
3641
4121
  if [ "$trigger_source" = issue ]; then
3642
4122
  if [ "$state" != open ]; then
3643
4123
  trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
@@ -3653,6 +4133,8 @@ verb_trigger() {
3653
4133
  "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`." ;;
3654
4134
  3) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${AUTH_PERMISSION:-nothing}\`." \
3655
4135
  "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\`." ;;
4136
+ 5) trigger_refuse "$AUTH_WHY" \
4137
+ "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\`." ;;
3656
4138
  *) trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
3657
4139
  "Re-apply the label \`$trigger_label\` to try again." ;;
3658
4140
  esac
@@ -3746,9 +4228,12 @@ The task is the dispatch's \`client_payload\` title and body. Sending the same d
3746
4228
  # never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
3747
4229
  #
3748
4230
  # THE TARGET RULE. A comment goes to the open same-repository pull request
3749
- # whose head is the branch when `forge_recognised` holds for that branch, else
3750
- # to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
3751
- # state label goes on that issue and on that pull request, each when known.
4231
+ # whose head is the branch when `forge_recognised` holds for that branch — its
4232
+ # origin tip carries its task prompt or its flow-progress ledger — else to the
4233
+ # issue the run was started from (`FORGE_ISSUE`), else nowhere. The task prompt
4234
+ # counts so that a pull request opened at the run's start is a target before
4235
+ # the ledger's first push. The state label goes on that issue and on that pull
4236
+ # request, each when known.
3752
4237
  #
3753
4238
  # THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
3754
4239
  # (`remote_state`); the harness overwrites any state label set by hand, and
@@ -3807,14 +4292,12 @@ forge_fetch_branch() {
3807
4292
  return 0
3808
4293
  }
3809
4294
 
3810
- # forge_marker <event> <branch> [<question>] — the one producer of a comment's
3811
- # marker line, built from COMMENT_MARKER.
4295
+ # forge_marker <event> <branch> [<question> [<engine> [<round>]]] — the one
4296
+ # producer of a comment's marker line, built from COMMENT_MARKER: ` question=<n>`,
4297
+ # ` engine=<engine>` then ` round=<n>`, each only when non-empty, before ` -->`.
3812
4298
  forge_marker() {
3813
- if [ -n "${3-}" ]; then
3814
- printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
3815
- else
3816
- printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
3817
- fi
4299
+ printf '%s event=%s branch=%s%s%s%s -->\n' "$COMMENT_MARKER" "$1" "$2" \
4300
+ "${3:+ question=$3}" "${4:+ engine=$4}" "${5:+ round=$5}"
3818
4301
  }
3819
4302
 
3820
4303
  # forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
@@ -3895,38 +4378,105 @@ forge_provenance_parse() {
3895
4378
  }
3896
4379
 
3897
4380
  # forge_recognised <branch> — the harness-branch test, read from committed
3898
- # state: 0 when origin's copy of the branch carries its flow-progress ledger.
4381
+ # state: 0 when origin's copy of the branch carries its task prompt or its
4382
+ # flow-progress ledger, 1 otherwise.
3899
4383
  forge_recognised() {
3900
4384
  local state_rel
3901
4385
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
3902
4386
  [ -n "$state_rel" ] || return 1
3903
- git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null
4387
+ git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null \
4388
+ || git -C "$root" cat-file -e "refs/remotes/origin/$1:$(hr_task_prompt_rel "$state_rel" "$1")" 2>/dev/null
3904
4389
  }
3905
4390
 
3906
4391
  # forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
3907
- # <branch> in this repository (a fork's same-named head is skipped), or empty.
4392
+ # <branch> in this repository (a fork's same-named head is skipped), or empty;
4393
+ # FORGE_PR_DRAFT, that same pull request's `isDraft` as `true` or `false`, or
4394
+ # empty when FORGE_PR is.
3908
4395
  FORGE_PR=""
4396
+ FORGE_PR_DRAFT=""
3909
4397
  forge_pr_var() {
4398
+ local sel
3910
4399
  FORGE_PR=""
3911
- if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository --limit 10; then
4400
+ FORGE_PR_DRAFT=""
4401
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository,isDraft --limit 10; then
3912
4402
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3913
4403
  return 1
3914
4404
  fi
3915
- if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
3916
- 'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
3917
- FORGE_PR=""
4405
+ if ! sel=$(printf '%s' "$GH_OUT" | jq -r \
4406
+ 'if type == "array" then [.[] | select(.isCrossRepository == false and (.number | type) == "number")] | first // empty | "\(.number) \(.isDraft == true)" else error end' 2>/dev/null); then
3918
4407
  GH_ERR="its pr list is not the expected JSON"
3919
4408
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3920
4409
  return 1
3921
4410
  fi
4411
+ if [ -n "$sel" ]; then
4412
+ FORGE_PR="${sel%% *}"
4413
+ FORGE_PR_DRAFT="${sel#* }"
4414
+ fi
3922
4415
  return 0
3923
4416
  }
3924
4417
 
3925
- # forge_comment <number> <event> <branch> <body_file> [<question>] — append the
3926
- # marker to <body_file> and post it on issue or pull request <number>.
4418
+ # forge_dispatch_engine_var <branch> <created_at_iso> — FORGE_DISPATCH_ENGINE,
4419
+ # the engine the dispatch that created the run at <created_at_iso> recorded in
4420
+ # its comment on the branch's issue or open pull request, or empty. Only a
4421
+ # `github-actions[bot]` comment counts, whose last non-empty line is exactly a
4422
+ # `started` or `round` marker for <branch>, either with or without an engine,
4423
+ # or a `reply` marker for <branch> carrying an engine, and whose `created_at`
4424
+ # is no earlier than <created_at_iso> less DISPATCH_MARKER_SLACK_SECS. The
4425
+ # newest one answers: `started` is `task`, `round` is `user_review`, `reply`
4426
+ # its own engine. 1 when the forge is off or the repository is unknown. Fetches
4427
+ # origin's <branch> into the root checkout; replaces GH_OUT.
4428
+ FORGE_DISPATCH_ENGINE=""
4429
+ forge_dispatch_engine_var() {
4430
+ local b="$1" created="${2-}" since markers listed="" n engine answer
4431
+ FORGE_DISPATCH_ENGINE=""
4432
+ forge_on || return 1
4433
+ forge_repo_var || return 1
4434
+ if ! since=$(jq -n -r --arg t "$created" --argjson o "$DISPATCH_MARKER_SLACK_SECS" '($t | fromdateiso8601) - $o' 2>/dev/null) \
4435
+ || [ -z "$since" ]; then
4436
+ echo "remote-run.sh: the newest run of $b has no readable createdAt ('$created'); its dispatch's engine is not read" >&2
4437
+ return 0
4438
+ fi
4439
+ # Every marker line that qualifies, each with the engine it answers.
4440
+ markers=""
4441
+ for engine in task user_review docs; do
4442
+ markers="$markers$(forge_marker started "$b" "" "$engine")"$'\t'task$'\n'
4443
+ markers="$markers$(forge_marker round "$b" "" "$engine")"$'\t'user_review$'\n'
4444
+ markers="$markers$(forge_marker reply "$b" "" "$engine")"$'\t'"$engine"$'\n'
4445
+ done
4446
+ markers="$markers$(forge_marker started "$b")"$'\t'task$'\n'
4447
+ markers="$markers$(forge_marker round "$b")"$'\t'user_review$'\n'
4448
+ markers=$(printf '%s' "$markers" | jq -R -s -c \
4449
+ '[split("\n")[] | select(length > 0) | split("\t") | {(.[0]): .[1]}] | add')
4450
+ forge_fetch_branch "$b"
4451
+ forge_issue_var "$b" || FORGE_ISSUE=""
4452
+ forge_pr_var "$b" || FORGE_PR=""
4453
+ for n in $FORGE_ISSUE $FORGE_PR; do
4454
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$n/comments" --jq '.[] | {login: .user.login, at: .created_at, body: .body}'; then
4455
+ echo "remote-run.sh: listing the comments of #$n failed, so the engine of $b's dispatch is not read: $GH_ERR" >&2
4456
+ return 0
4457
+ fi
4458
+ listed="$listed$GH_OUT"$'\n'
4459
+ done
4460
+ if ! answer=$(printf '%s' "$listed" | jq -s -r --argjson m "$markers" --argjson since "$since" '
4461
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.at | type) == "string")
4462
+ | ((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "") as $last
4463
+ | select($m[$last] != null)
4464
+ | (.at | try fromdateiso8601 catch null) as $t
4465
+ | select($t != null and $t >= $since)
4466
+ | {t: $t, e: $m[$last]}]
4467
+ | sort_by(.t) | last | .e // ""' 2>/dev/null); then
4468
+ echo "remote-run.sh: the comments of $b's issue and pull request are not the expected JSON, so the engine of its dispatch is not read" >&2
4469
+ return 0
4470
+ fi
4471
+ FORGE_DISPATCH_ENGINE="$answer"
4472
+ return 0
4473
+ }
4474
+
4475
+ # forge_comment <number> <event> <branch> <body_file> [<question> [<engine> [<round>]]]
4476
+ # — append the marker to <body_file> and post it on issue or pull request <number>.
3927
4477
  forge_comment() {
3928
- local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
3929
- if ! { printf '\n'; forge_marker "$event" "$branch" "$question"; } >>"$file"; then
4478
+ local number="$1" event="$2" branch="$3" file="$4" question="${5-}" engine="${6-}" round="${7-}" status
4479
+ if ! { printf '\n'; forge_marker "$event" "$branch" "$question" "$engine" "$round"; } >>"$file"; then
3930
4480
  echo "remote-run.sh: cannot append the marker to '$file'" >&2
3931
4481
  return 1
3932
4482
  fi
@@ -4039,13 +4589,22 @@ forge_question_body() {
4039
4589
  # comment (on `parked`, one per open question) and the state label, by the
4040
4590
  # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4041
4591
  # 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.
4592
+ # its issue read from the task prompt at <sha>. Every event but `stopped` is
4593
+ # withheld when the branch's newest `harness stop` run is newer than its newest
4594
+ # `harness run` run, read from a fresh listing. `not_started` reads
4595
+ # REPORT_NOT_STARTED_STATE (`paused`, else `failed`) and
4596
+ # REPORT_NOT_STARTED_ENGINE (empty or the recovered engine), set by its caller.
4597
+ # Always 0.
4598
+ REPORT_NOT_STARTED_STATE=""
4599
+ REPORT_NOT_STARTED_ENGINE=""
4043
4600
  forge_report() {
4044
4601
  local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
4045
- local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
4602
+ local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n route engine=""
4046
4603
  case "$event" in
4047
4604
  parked|park_loop) state=parked ;;
4048
4605
  paused) state=paused ;;
4606
+ not_started)
4607
+ if [ "$REPORT_NOT_STARTED_STATE" = paused ]; then state=paused; else state=failed; fi ;;
4049
4608
  resumed) state=running ;;
4050
4609
  failed) state=failed ;;
4051
4610
  stopped) state=stopped ;;
@@ -4066,7 +4625,9 @@ forge_report() {
4066
4625
  fi
4067
4626
  forge_repo_var || return 0
4068
4627
 
4069
- if [ "$event" = failed ]; then
4628
+ if [ "$event" != stopped ]; then
4629
+ # Re-listed: `review` dispatches a run and then reports `round` in one invocation.
4630
+ ALL_RUNS_LISTED=0
4070
4631
  remote_branch_stopped "$br"
4071
4632
  stopped=$?
4072
4633
  if [ "$stopped" -eq 0 ]; then
@@ -4074,7 +4635,7 @@ forge_report() {
4074
4635
  return 0
4075
4636
  fi
4076
4637
  [ "$stopped" -eq 1 ] \
4077
- || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
4638
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the $event" >&2
4078
4639
  fi
4079
4640
 
4080
4641
  [ "$event" = stopped ] || { pr=""; gone=""; }
@@ -4093,7 +4654,7 @@ forge_report() {
4093
4654
  forge_pr_var "$br" || FORGE_PR=""
4094
4655
  fi
4095
4656
  if [ -z "$pr" ] && [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
4096
- echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
4657
+ echo "remote-run.sh: report: pull request #$FORGE_PR's head carries neither a task prompt nor a flow-progress ledger; it is not a target"
4097
4658
  FORGE_PR=""
4098
4659
  fi
4099
4660
  if [ -n "$FORGE_PR" ]; then
@@ -4110,6 +4671,7 @@ forge_report() {
4110
4671
  if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
4111
4672
  reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
4112
4673
  resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
4674
+ [ "$event" != failed ] || engine=$(hr_registry_get "$registry_file" "$br" engine)
4113
4675
  fi
4114
4676
 
4115
4677
  case "$event" in
@@ -4125,10 +4687,13 @@ forge_report() {
4125
4687
  resumed)
4126
4688
  text="The harness run on \`$br\` resumed." ;;
4127
4689
  failed)
4128
- if [ "$kind" = pr ]; then
4129
- text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes."
4690
+ trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
4691
+ if [ "$kind" = pr ] && [ "$engine" = user_review ]; then
4692
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes. This pull request stays open."
4693
+ elif [ "$kind" = pr ]; then
4694
+ # No review route: a round would start over a task run that never completed.
4695
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. This draft pull request stays open: close it to discard the run${FORGE_ISSUE:+, or re-apply the label \`$trigger_label\` to issue #$FORGE_ISSUE to start a new run on the next indexed branch}."
4130
4696
  else
4131
- trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
4132
4697
  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."
4133
4698
  fi ;;
4134
4699
  stopped)
@@ -4139,9 +4704,28 @@ forge_report() {
4139
4704
  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
4705
  else
4141
4706
  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."
4707
+ [ "$kind" != pr ] || text="$text Its draft pull request stays open; closing it discards the run."
4142
4708
  fi ;;
4143
4709
  round)
4144
- text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
4710
+ text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again."
4711
+ # The undo runs before the comment is written, so the comment states its outcome.
4712
+ if [ "$kind" = pr ] && [ "$FORGE_PR_DRAFT" = false ]; then
4713
+ if gh_call pr ready "$FORGE_PR" --repo "$FORGE_REPO" --undo; then
4714
+ text="$text This pull request is a draft again until the round completes."
4715
+ else
4716
+ echo "::warning::remote-run.sh: report: turning pull request #$FORGE_PR back to a draft was refused: $GH_ERR"
4717
+ text="$text Turning this pull request back to a draft was refused, so it stays ready for review while the round works."
4718
+ fi
4719
+ fi ;;
4720
+ not_started)
4721
+ text="GitHub did not start the job of the harness run on \`$br\`, so nothing ran and the branch is unchanged."
4722
+ if [ "$state" = paused ] && [ -n "$REPORT_NOT_STARTED_ENGINE" ]; then
4723
+ text="$text Comment \`${COMMAND_HANDLE} resume\` to start it again."
4724
+ else
4725
+ # A job that never started uploaded no artifact to read an engine from.
4726
+ route=$(hr_github_resume_route "$br" "${REPORT_NOT_STARTED_ENGINE:-<task, user_review or docs: the one the run was started with>}")
4727
+ text="$text Start it again with the **Run workflow** form: ${route#or from GitHub: }."
4728
+ fi ;;
4145
4729
  esac
4146
4730
 
4147
4731
  OPEN_QUESTIONS=""
@@ -4193,8 +4777,122 @@ forge_report() {
4193
4777
  return 0
4194
4778
  }
4195
4779
 
4780
+ # forge_progress <branch> — upsert the one progress comment of the run or round
4781
+ # on its pull request, rendered from the job checkout's flow-progress ledger:
4782
+ # created when none is listed, edited in place only when its body differs. No
4783
+ # issue fallback, no label. Always 0.
4784
+ forge_progress() {
4785
+ local br="$1" status state_rel phases engine round p line i first=1 marker
4786
+ local tmp made_tmp="" file pick id same
4787
+ local -a labels states
4788
+ if ! forge_on; then
4789
+ echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4790
+ return 0
4791
+ fi
4792
+ status=0
4793
+ hr_progress_comments "$root" || status=$?
4794
+ case "$status" in
4795
+ 0) ;;
4796
+ 1) echo "remote-run.sh: report: progress comments are off by execution.progressComments; nothing posted"; return 0 ;;
4797
+ *) echo "remote-run.sh: report: execution.progressComments is unreadable; nothing posted"; return 0 ;;
4798
+ esac
4799
+ forge_repo_var || return 0
4800
+ ALL_RUNS_LISTED=0
4801
+ status=0
4802
+ remote_branch_stopped "$br" || status=$?
4803
+ if [ "$status" -eq 0 ]; then
4804
+ echo "remote-run.sh: report: $br was stopped, and the stop already reported the run; nothing posted"
4805
+ return 0
4806
+ fi
4807
+ [ "$status" -eq 1 ] \
4808
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the progress" >&2
4809
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
4810
+ if [ -z "$state_rel" ] || ! phases=$(hr_ledger_phases "$root/${state_rel%/}/flow_progress/${br}_progress.md"); then
4811
+ echo "remote-run.sh: report: $br has no task or user-review ledger; nothing posted"
4812
+ return 0
4813
+ fi
4814
+ read -r engine round states[0] states[1] states[2] states[3] <<<"$phases"
4815
+ forge_fetch_branch "$br"
4816
+ forge_pr_var "$br" || FORGE_PR=""
4817
+ if [ -z "$FORGE_PR" ] || ! forge_recognised "$br"; then
4818
+ echo "remote-run.sh: report: $br has no recognised open pull request; nothing posted"
4819
+ return 0
4820
+ fi
4821
+
4822
+ if [ "$engine" = user_review ]; then
4823
+ labels=('Fix plan' 'Fix implementation' 'Branch review' 'Done')
4824
+ line="Progress of user-review round $round on \`$br\`:"
4825
+ else
4826
+ labels=('Planning' 'Implementation' 'Branch review' 'Done')
4827
+ line="Progress of the harness run on \`$br\`:"
4828
+ round=""
4829
+ fi
4830
+ marker=$(forge_marker progress "$br" "" "" "$round")
4831
+
4832
+ tmp="${RUNNER_TEMP-}"
4833
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4834
+ tmp=$(mktemp -d) || tmp=""
4835
+ made_tmp="$tmp"
4836
+ fi
4837
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-progress-comment.XXXXXX"); then
4838
+ echo "::warning::remote-run.sh: report: cannot create the progress comment file for #$FORGE_PR; nothing posted"
4839
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4840
+ return 0
4841
+ fi
4842
+ # No timestamp and no run URL: an unchanged ledger must render byte-identical.
4843
+ # Plain list items: a `- [ ]` box is clickable by anyone with write access.
4844
+ {
4845
+ printf '%s\n\n' "$line"
4846
+ for i in 0 1 2 3; do
4847
+ if [ "${states[$i]}" = done ]; then
4848
+ p=done
4849
+ elif [ "$first" -eq 1 ]; then
4850
+ p="in progress"; first=0
4851
+ else
4852
+ p="not started"
4853
+ fi
4854
+ printf -- '- %s: %s\n' "${labels[$i]}" "$p"
4855
+ done
4856
+ } >"$file"
4857
+ { cat "$file"; printf '\n%s\n' "$marker"; } >"$file.full"
4858
+
4859
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$FORGE_PR/comments" --jq '.[] | {id, login: .user.login, body}'; then
4860
+ echo "::warning::remote-run.sh: report: listing the comments of #$FORGE_PR was refused, so the progress is not posted: $GH_ERR"
4861
+ elif ! pick=$(printf '%s' "$GH_OUT" | jq -s -r --arg m "$marker" --arg want "$(cat "$file.full")" '
4862
+ def norm: gsub("\r"; "") | sub("\n+$"; "");
4863
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.id | type) == "number")
4864
+ | select(((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "") == $m)]
4865
+ | max_by(.id) // empty
4866
+ | "\(.id) \(if ((.body // "") | norm) == ($want | norm) then "same" else "differs" end)"' 2>/dev/null); then
4867
+ echo "::warning::remote-run.sh: report: the comments of #$FORGE_PR are not the expected JSON, so the progress is not posted"
4868
+ elif [ -z "$pick" ]; then
4869
+ if forge_comment "$FORGE_PR" progress "$br" "$file" "" "" "$round"; then
4870
+ echo "remote-run.sh: report: progress on $br posted on #$FORGE_PR"
4871
+ else
4872
+ echo "::warning::remote-run.sh: report: posting the progress comment on #$FORGE_PR was refused: $GH_ERR"
4873
+ fi
4874
+ else
4875
+ id="${pick%% *}"
4876
+ same="${pick#* }"
4877
+ if [ "$same" = same ]; then
4878
+ echo "remote-run.sh: report: the progress comment on #$FORGE_PR is already current"
4879
+ elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$id" -F "body=@$file.full"; then
4880
+ echo "remote-run.sh: report: progress on $br edited in comment $id on #$FORGE_PR"
4881
+ else
4882
+ echo "::warning::remote-run.sh: report: editing the progress comment $id on #$FORGE_PR was refused: $GH_ERR"
4883
+ fi
4884
+ fi
4885
+ rm -f "$file" "$file.full"
4886
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4887
+ return 0
4888
+ }
4889
+
4196
4890
  verb_report() {
4197
- forge_report "$report_event" "$branch" "$report_note"
4891
+ if [ "$report_event" = progress ]; then
4892
+ forge_progress "$branch"
4893
+ else
4894
+ forge_report "$report_event" "$branch" "$report_note"
4895
+ fi
4198
4896
  exit "$EXIT_OK"
4199
4897
  }
4200
4898
 
@@ -4229,7 +4927,7 @@ deliver_pr_body() {
4229
4927
  verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
4230
4928
  done
4231
4929
  {
4232
- printf 'This pull request carries the harness run on `%s`, ready for your review. The harness never merges it.\n' "$2"
4930
+ printf 'This pull request carries the harness run on `%s`. It stays a draft while the run works, and the harness marks it ready for review when the run completes; the harness never merges it.\n' "$2"
4233
4931
  [ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
4234
4932
  printf '\nA review that requests changes starts a user-review round on this branch.\n'
4235
4933
  printf 'Comment %s to act on the run; each says when it applies (docs/github-run-control.md in the harness documentation).\n' "$verbs"
@@ -4251,11 +4949,280 @@ deliver_create() {
4251
4949
  fi
4252
4950
  }
4253
4951
 
4254
- # verb_deliver — after a `completed` bundle: find or open the branch's pull
4255
- # request, post the one `completed` comment, and set `done`. Always exit 0.
4952
+ # forge_open_pr <branch> — the one opener of a run's pull request, against
4953
+ # `defaultBranch`: a `--draft` create, then on any failure but
4954
+ # PR_CREATE_FORBIDDEN one retry without `--draft`. The caller has set
4955
+ # FORGE_REPO, FORGE_SERVER and FORGE_ISSUE. 0 when opened, setting FORGE_PR,
4956
+ # FORGE_PR_URL and FORGE_PR_DRAFT (`false` after the retry); 1 when not,
4957
+ # setting OPEN_ERR (one line) and OPEN_FORBIDDEN (1 on PR_CREATE_FORBIDDEN).
4958
+ # Always sets OPEN_BASE, empty when unreadable. Never posts, never exits.
4959
+ FORGE_PR_URL=""
4960
+ OPEN_ERR=""
4961
+ OPEN_FORBIDDEN=0
4962
+ OPEN_BASE=""
4963
+ forge_open_pr() {
4964
+ local br="$1" tmp made_tmp="" body="" draft=""
4965
+ FORGE_PR=""
4966
+ FORGE_PR_URL=""
4967
+ FORGE_PR_DRAFT=""
4968
+ OPEN_ERR=""
4969
+ OPEN_FORBIDDEN=0
4970
+ OPEN_BASE=$(hr_default_branch "$root" 2>/dev/null) || OPEN_BASE=""
4971
+ deliver_title_var "$br"
4972
+ if [ -z "$OPEN_BASE" ]; then
4973
+ OPEN_ERR="the configured defaultBranch could not be read"
4974
+ return 1
4975
+ fi
4976
+ tmp="${RUNNER_TEMP-}"
4977
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4978
+ tmp=$(mktemp -d) || tmp=""
4979
+ made_tmp="$tmp"
4980
+ fi
4981
+ if [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-pr-body.XXXXXX") || ! deliver_pr_body "$body" "$br"; then
4982
+ OPEN_ERR="its body file could not be written"
4983
+ elif deliver_create "$OPEN_BASE" "$br" "$body" --draft; then
4984
+ draft=true
4985
+ else
4986
+ OPEN_ERR="$GH_ERR"
4987
+ case "$GH_ERR" in
4988
+ *"$PR_CREATE_FORBIDDEN"*) OPEN_FORBIDDEN=1 ;;
4989
+ *)
4990
+ # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4991
+ echo "remote-run.sh: $verb: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4992
+ if deliver_create "$OPEN_BASE" "$br" "$body"; then
4993
+ draft=false
4994
+ else
4995
+ OPEN_ERR="$GH_ERR"
4996
+ fi ;;
4997
+ esac
4998
+ fi
4999
+ [ -z "$body" ] || rm -f "$body"
5000
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5001
+ [ -n "$draft" ] || return 1
5002
+ FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
5003
+ if [ -z "$FORGE_PR" ]; then
5004
+ OPEN_ERR="gh printed no pull request URL"
5005
+ return 1
5006
+ fi
5007
+ FORGE_PR_URL="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
5008
+ FORGE_PR_DRAFT="$draft"
5009
+ return 0
5010
+ }
5011
+
5012
+ # deliver_comment <number> <tmp> <text> — one `completed` comment on <number>:
5013
+ # <text>, the `phases.qa` sentence when that phase is on, this run's URL when
5014
+ # GITHUB_RUN_ID is set, then the marker. A failure is one line.
5015
+ deliver_comment() {
5016
+ local number="$1" tmp="$2" text="$3" file
5017
+ if hr_phase_enabled "$root" qa; then
5018
+ text="$text
5019
+
5020
+ The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
5021
+ fi
5022
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
5023
+ echo "remote-run.sh: deliver: cannot create the comment file for #$number; no comment posted" >&2
5024
+ return 0
5025
+ fi
5026
+ {
5027
+ printf '%s\n' "$text"
5028
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
5029
+ } >"$file"
5030
+ forge_comment "$number" completed "$branch" "$file" || :
5031
+ rm -f "$file"
5032
+ }
5033
+
5034
+ # deliver_thread_reply <tmp> <comment_id> <text> — one reply to the review
5035
+ # thread holding <comment_id>, <text> then the `thread` marker. 0 when posted;
5036
+ # otherwise one `::warning::` line and 1.
5037
+ deliver_thread_reply() {
5038
+ local tmp="$1" id="$2" text="$3" file
5039
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-thread-reply.XXXXXX"); then
5040
+ echo "::warning::remote-run.sh: deliver: cannot create the reply file for comment $id; no reply posted"
5041
+ return 1
5042
+ fi
5043
+ if ! { printf '%s\n\n' "$text"; forge_marker thread "$branch" "" "" "$RC_MARKED_N"; } >"$file"; then
5044
+ rm -f "$file"
5045
+ echo "::warning::remote-run.sh: deliver: cannot write the reply file for comment $id; no reply posted"
5046
+ return 1
5047
+ fi
5048
+ if ! gh_call api --method POST "repos/$FORGE_REPO/pulls/$FORGE_PR/comments/$id/replies" -F "body=@$file"; then
5049
+ rm -f "$file"
5050
+ echo "::warning::remote-run.sh: deliver: the reply to review comment $id was refused: $GH_ERR"
5051
+ return 1
5052
+ fi
5053
+ rm -f "$file"
5054
+ return 0
5055
+ }
5056
+
5057
+ # deliver_round_threads <tmp> — at a round's completion, handle each inline
5058
+ # review thread the round collected by the fix plan's verdict on its comment:
5059
+ # `fixed` is a reply naming the fix commit, then the thread resolved; `reason`
5060
+ # is a reply quoting it, the thread left open. The wire it reads is the
5061
+ # header's THE REVIEW-COMMENT LINE. Never dismisses a review; never fails.
5062
+ deliver_round_threads() {
5063
+ local tmp="$1" plan index_rel dir_rel index names path name k line rest id ids
5064
+ local sha tip since pages threads row t_id t_resolved t_ids t_root t_later t_own
5065
+ local i found handled="," own_marker re
5066
+ local -a v_id=() v_kind=() v_val=()
5067
+ if ! round_markers_read ""; then
5068
+ echo "::warning::remote-run.sh: deliver: the round's review threads were not read: $RC_ERR"
5069
+ return 0
5070
+ fi
5071
+ if [ "$RC_NEWEST_N" -eq 0 ]; then
5072
+ echo "remote-run.sh: deliver: $branch has no round file; no review thread handled"
5073
+ return 0
5074
+ fi
5075
+ if [ "$RC_NEWEST_N" -ne "$RC_MARKED_N" ]; then
5076
+ echo "remote-run.sh: deliver: round $RC_NEWEST_N of $branch was not placed from a pull-request review; no review thread handled"
5077
+ return 0
5078
+ fi
5079
+ if [ -z "$RC_MARKED_C" ]; then
5080
+ echo "remote-run.sh: deliver: round $RC_MARKED_N of $branch collected no inline comment; no review thread handled"
5081
+ return 0
5082
+ fi
5083
+
5084
+ plan="${branch}_fix_plan"
5085
+ [ "$RC_MARKED_N" -eq 1 ] || plan="${plan}_$RC_MARKED_N"
5086
+ index_rel="$RC_STATE_REL/user_reviews/$plan.md"
5087
+ dir_rel="$RC_STATE_REL/user_reviews/$plan"
5088
+ if ! index=$(git -C "$root" show "refs/remotes/origin/$branch:$index_rel" 2>/dev/null); then
5089
+ echo "remote-run.sh: deliver: round $RC_MARKED_N of $branch has no fix plan at $index_rel; no review thread handled"
5090
+ return 0
5091
+ fi
5092
+ tip=$(git -C "$root" rev-parse "refs/remotes/origin/$branch" 2>/dev/null) || tip=""
5093
+
5094
+ # Verdicts, kept only for ids the round collected; the first verdict an id gets wins.
5095
+ names=$(git -C "$root" ls-tree --name-only "refs/remotes/origin/$branch" -- "$dir_rel/" 2>/dev/null) || names=""
5096
+ while IFS= read -r path; do
5097
+ name="${path##*/}"
5098
+ [[ "$name" =~ ^finding_([0-9]+)\.md$ ]] || continue
5099
+ k=$((10#${BASH_REMATCH[1]}))
5100
+ case "$index" in *"[x] **Finding $k**"*) ;; *) continue ;; esac
5101
+ line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null | grep -E '^\*\*Review comments:\*\* ' | head -n 1) || line=""
5102
+ [ -n "$line" ] || continue
5103
+ sha=$(git -C "$root" log --reverse --format=%H -S "[x] **Finding $k**" "refs/remotes/origin/$branch" -- "$index_rel" 2>/dev/null | head -n 1) || sha=""
5104
+ [ -n "$sha" ] || sha="$tip"
5105
+ rest="${line#'**Review comments:** '}"
5106
+ rest="${rest%$'\r'}"
5107
+ ids="${rest//[[:space:]]/}"
5108
+ for id in ${ids//,/ }; do
5109
+ [[ "$id" =~ ^[0-9]+$ ]] || continue
5110
+ v_id+=("$id"); v_kind+=(fixed); v_val+=("$sha")
5111
+ done
5112
+ done <<NAMES
5113
+ $names
5114
+ NAMES
5115
+ re='^- (.*) \*\*Review comments:\*\* ([0-9][0-9, ]*)$'
5116
+ found=0
5117
+ while IFS= read -r line; do
5118
+ line="${line%$'\r'}"
5119
+ case "$line" in
5120
+ '## Out of scope / verified-OK'*) found=1; continue ;;
5121
+ '## '*) found=0; continue ;;
5122
+ esac
5123
+ [ "$found" -eq 1 ] || continue
5124
+ [[ "$line" =~ $re ]] || continue
5125
+ rest="${BASH_REMATCH[1]}"
5126
+ ids="${BASH_REMATCH[2]//[[:space:]]/}"
5127
+ for id in ${ids//,/ }; do
5128
+ [[ "$id" =~ ^[0-9]+$ ]] || continue
5129
+ v_id+=("$id"); v_kind+=(reason); v_val+=("$rest")
5130
+ done
5131
+ done <<INDEX
5132
+ $index
5133
+ INDEX
5134
+
5135
+ if ! since=$(jq -n -r --arg t "$RC_MARKED_AT" '$t | fromdateiso8601' 2>/dev/null) || [ -z "$since" ]; then
5136
+ echo "::warning::remote-run.sh: deliver: round $RC_MARKED_N's collected_at '$RC_MARKED_AT' is not a UTC time; no review thread handled"
5137
+ return 0
5138
+ fi
5139
+ if ! gh_call api graphql --paginate -f "owner=${FORGE_REPO%%/*}" -f "name=${FORGE_REPO#*/}" -F "number=$FORGE_PR" \
5140
+ -f 'query=query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes { id isResolved comments(first: 100) { nodes { databaseId createdAt body } } } } } } }'; then
5141
+ echo "::warning::remote-run.sh: deliver: listing the review threads of pull request #$FORGE_PR was refused: $GH_ERR"
5142
+ return 0
5143
+ fi
5144
+ pages="$GH_OUT"
5145
+ own_marker=$(forge_marker thread "$branch" "" "" "$RC_MARKED_N")
5146
+ # One `|`-joined row per thread (a GraphQL node id carries no `|`): id,
5147
+ # resolved, its comment ids, its first comment's id, whether a comment came
5148
+ # after the round, whether it carries this harness's reply.
5149
+ if ! threads=$(printf '%s' "$pages" | jq -s -r --argjson since "$since" --arg collected ",$RC_MARKED_C," \
5150
+ --arg marker "$COMMENT_MARKER" --arg own "$own_marker" '
5151
+ [ .[] | .data.repository.pullRequest.reviewThreads.nodes[] ]
5152
+ | .[] | (.comments.nodes // []) as $c
5153
+ | [ .id, (.isResolved == true | tostring),
5154
+ ($c | map(.databaseId | tostring) | join(",")),
5155
+ (($c[0].databaseId // "") | tostring),
5156
+ (any($c[]; ("," + (.databaseId | tostring) + ",") as $k
5157
+ | ((.createdAt // "") | try fromdateiso8601 catch 0) > $since
5158
+ and (($collected | contains($k)) | not)
5159
+ and (((.body // "") | contains($marker)) | not)) | tostring),
5160
+ (any($c[]; (.body // "") | contains($own)) | tostring) ]
5161
+ | join("|")' 2>/dev/null); then
5162
+ echo "::warning::remote-run.sh: deliver: the review threads of pull request #$FORGE_PR are not the expected JSON; no review thread handled"
5163
+ return 0
5164
+ fi
5165
+
5166
+ for id in ${RC_MARKED_C//,/ }; do
5167
+ i=0; found=""
5168
+ while [ "$i" -lt "${#v_id[@]}" ]; do
5169
+ if [ "${v_id[$i]}" = "$id" ]; then found="$i"; break; fi
5170
+ i=$((i + 1))
5171
+ done
5172
+ if [ -z "$found" ]; then
5173
+ echo "remote-run.sh: deliver: review comment $id has no verdict in the fix plan; left alone"
5174
+ continue
5175
+ fi
5176
+ row=""
5177
+ while IFS= read -r line; do
5178
+ [ -n "$line" ] || continue
5179
+ IFS='|' read -r t_id t_resolved t_ids t_root t_later t_own <<<"$line"
5180
+ case ",$t_ids," in *",$id,"*) row="$line"; break ;; esac
5181
+ done <<THREADS
5182
+ $threads
5183
+ THREADS
5184
+ if [ -z "$row" ]; then
5185
+ echo "remote-run.sh: deliver: review comment $id is in no review thread of pull request #$FORGE_PR; left alone"
5186
+ continue
5187
+ fi
5188
+ case "$handled" in
5189
+ *",$t_id,"*) echo "remote-run.sh: deliver: review comment $id's thread was handled for another comment; left alone"; continue ;;
5190
+ esac
5191
+ handled="$handled$t_id,"
5192
+ if [ "$t_resolved" = true ]; then
5193
+ echo "remote-run.sh: deliver: review comment $id's thread is already resolved; left alone"
5194
+ continue
5195
+ fi
5196
+ if [ "$t_later" = true ]; then
5197
+ echo "remote-run.sh: deliver: review comment $id's thread has a reply newer than round $RC_MARKED_N; left alone"
5198
+ continue
5199
+ fi
5200
+ if [ "$t_own" = true ]; then
5201
+ echo "remote-run.sh: deliver: review comment $id's thread already carries this harness's reply; left alone"
5202
+ continue
5203
+ fi
5204
+ # Replies attach to a thread's first comment: GitHub does not take a reply to a reply.
5205
+ if [ "${v_kind[$found]}" = fixed ]; then
5206
+ deliver_thread_reply "$tmp" "$t_root" "Addressed in \`${v_val[$found]}\`." || continue
5207
+ if gh_call api graphql -f 'query=mutation($id: ID!) { resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } }' -f "id=$t_id"; then
5208
+ echo "remote-run.sh: deliver: replied to review comment $id and resolved its thread"
5209
+ else
5210
+ echo "::warning::remote-run.sh: deliver: resolving review comment $id's thread was refused: $GH_ERR"
5211
+ fi
5212
+ else
5213
+ deliver_thread_reply "$tmp" "$t_root" "Not changed in this round: ${v_val[$found]}" || continue
5214
+ echo "remote-run.sh: deliver: replied to review comment $id with the reason; its thread stays open"
5215
+ fi
5216
+ done
5217
+ return 0
5218
+ }
5219
+
5220
+ # verb_deliver — after a `completed` bundle: reuse the branch's pull request or
5221
+ # open it, mark it ready, post `completed` on it and on the issue, and set
5222
+ # `done`. Always exit 0.
4256
5223
  verb_deliver() {
4257
- local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
4258
- local tmp made_tmp="" body="" file target text compare
5224
+ local status engine noun verb_done pr_url="" lookup_failed=0 create_err="" forbidden=0 base=""
5225
+ local tmp made_tmp="" ready="" text readiness compare posted=""
4259
5226
  if ! forge_on; then
4260
5227
  echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4261
5228
  exit "$EXIT_OK"
@@ -4266,99 +5233,159 @@ verb_deliver() {
4266
5233
  echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
4267
5234
  exit "$EXIT_OK"
4268
5235
  fi
5236
+ engine=$(hr_remote_status_get "$bundle_dir/$HR_REMOTE_STATUS_FILE" engine 2>/dev/null) || engine=""
5237
+ if [ "$engine" = user_review ]; then
5238
+ noun=round; verb_done=finished
5239
+ else
5240
+ noun=run; verb_done=completed
5241
+ fi
4269
5242
  forge_repo_var || exit "$EXIT_OK"
4270
5243
 
4271
5244
  forge_fetch_branch "$branch"
4272
5245
  forge_issue_var "$branch" || FORGE_ISSUE=""
4273
- forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4274
-
4275
- tmp="${RUNNER_TEMP-}"
4276
- if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4277
- tmp=$(mktemp -d) || tmp=""
4278
- made_tmp="$tmp"
4279
- fi
5246
+ forge_pr_var "$branch" || { FORGE_PR=""; FORGE_PR_DRAFT=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4280
5247
 
4281
5248
  if [ -n "$FORGE_PR" ]; then
4282
5249
  pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4283
5250
  echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
4284
5251
  elif [ "$lookup_failed" -eq 0 ]; then
4285
- base=$(hr_default_branch "$root" 2>/dev/null) || base=""
4286
- deliver_title_var "$branch"
4287
- if [ -z "$base" ]; then
4288
- create_err="the configured defaultBranch could not be read"
4289
- elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
4290
- create_err="its body file could not be written"
4291
- elif deliver_create "$base" "$branch" "$body" --draft; then
4292
- opened=1
5252
+ echo "remote-run.sh: deliver: $branch has no open pull request at completion — the start's attempt failed or the workflow predates the open step; opening it now"
5253
+ if forge_open_pr "$branch"; then
5254
+ pr_url="$FORGE_PR_URL"
5255
+ echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
4293
5256
  else
4294
- create_err="$GH_ERR"
4295
- case "$GH_ERR" in
4296
- *"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
4297
- *)
4298
- # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4299
- echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4300
- if deliver_create "$base" "$branch" "$body"; then
4301
- opened=1
4302
- else
4303
- create_err="$GH_ERR"
4304
- fi ;;
4305
- esac
5257
+ create_err="$OPEN_ERR"
5258
+ forbidden="$OPEN_FORBIDDEN"
4306
5259
  fi
4307
- [ -z "$body" ] || rm -f "$body"
4308
- if [ "$opened" -eq 1 ]; then
4309
- FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
4310
- if [ -n "$FORGE_PR" ]; then
4311
- pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4312
- echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
5260
+ base="$OPEN_BASE"
5261
+ fi
5262
+
5263
+ # The flip runs with the job's token, never HARNESS_PR_TOKEN.
5264
+ if [ -n "$FORGE_PR" ]; then
5265
+ if [ "$FORGE_PR_DRAFT" = true ]; then
5266
+ if gh_call pr ready "$FORGE_PR" --repo "$FORGE_REPO"; then
5267
+ ready=flipped
5268
+ echo "remote-run.sh: deliver: marked pull request #$FORGE_PR ready for review"
4313
5269
  else
4314
- opened=0
4315
- create_err="gh printed no pull request URL"
5270
+ ready=refused
5271
+ echo "::warning::remote-run.sh: deliver: marking pull request #$FORGE_PR ready for review was refused: $GH_ERR"
4316
5272
  fi
5273
+ else
5274
+ ready=not-draft
4317
5275
  fi
4318
5276
  fi
4319
5277
 
4320
- compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4321
- if [ -z "$pr_url" ]; then
4322
- target="$FORGE_ISSUE"
5278
+ tmp="${RUNNER_TEMP-}"
5279
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5280
+ tmp=$(mktemp -d) || tmp=""
5281
+ made_tmp="$tmp"
5282
+ fi
5283
+
5284
+ # After the flip, before the comments.
5285
+ if [ "$noun" = round ] && [ -n "$FORGE_PR" ]; then
5286
+ deliver_round_threads "$tmp"
5287
+ fi
5288
+
5289
+ if [ -n "$FORGE_PR" ]; then
5290
+ case "$ready" in
5291
+ flipped)
5292
+ if [ "$noun" = round ]; then
5293
+ readiness="The harness round on \`$branch\` finished, and this pull request is marked ready for review again."
5294
+ else
5295
+ readiness="The harness run on \`$branch\` completed, and this pull request is now marked ready for your review."
5296
+ fi ;;
5297
+ not-draft)
5298
+ readiness="The harness $noun on \`$branch\` $verb_done. This pull request is not a draft — drafts may not be available on this repository's plan, or someone marked it ready — so it was left as it is." ;;
5299
+ *)
5300
+ readiness="The harness $noun on \`$branch\` $verb_done. Marking this draft ready for review was refused; mark it ready by hand." ;;
5301
+ esac
5302
+ deliver_comment "$FORGE_PR" "$tmp" "$readiness A review that requests changes starts another round."
5303
+ posted="#$FORGE_PR"
5304
+ if [ -n "$FORGE_ISSUE" ]; then
5305
+ if [ "$ready" = refused ]; then
5306
+ text="The harness $noun on \`$branch\` $verb_done. Its pull request #$FORGE_PR is still a draft — marking it ready for review was refused, so mark it ready by hand: $pr_url"
5307
+ elif [ "$noun" = round ]; then
5308
+ text="The harness round on \`$branch\` finished. Pull request #$FORGE_PR is ready for review again: $pr_url"
5309
+ else
5310
+ text="The harness run on \`$branch\` completed. Its pull request #$FORGE_PR is ready for your review: $pr_url"
5311
+ fi
5312
+ deliver_comment "$FORGE_ISSUE" "$tmp" "$text
5313
+ Review it there; a review that requests changes starts another round."
5314
+ posted="$posted and #$FORGE_ISSUE"
5315
+ fi
5316
+ elif [ -n "$FORGE_ISSUE" ]; then
5317
+ compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4323
5318
  if [ "$forbidden" -eq 1 ]; then
4324
- text="The harness run on \`$branch\` completed, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
5319
+ text="The harness $noun on \`$branch\` $verb_done, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
4325
5320
  else
4326
- text="The harness run on \`$branch\` completed, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
5321
+ text="The harness $noun on \`$branch\` $verb_done, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
4327
5322
  fi
4328
- elif [ "$opened" -eq 0 ]; then
4329
- target="$FORGE_PR"
4330
- text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
4331
- elif [ -n "$FORGE_ISSUE" ]; then
4332
- target="$FORGE_ISSUE"
4333
- text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
4334
- Review it there; a review that requests changes starts another round."
5323
+ deliver_comment "$FORGE_ISSUE" "$tmp" "$text"
5324
+ posted="#$FORGE_ISSUE"
4335
5325
  else
4336
- target="$FORGE_PR"
4337
- text="The harness run on \`$branch\` completed and opened this pull request. Review it; a review that requests changes starts another round."
5326
+ echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4338
5327
  fi
4339
- if hr_phase_enabled "$root" qa; then
4340
- text="$text
5328
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4341
5329
 
4342
- The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
4343
- fi
5330
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
5331
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
5332
+ echo "remote-run.sh: deliver: completed on $branch reported${posted:+ on $posted}"
5333
+ exit "$EXIT_OK"
5334
+ }
4344
5335
 
4345
- if [ -z "$target" ]; then
4346
- echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4347
- elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
5336
+ # verb_open — at the run's start: open the branch's draft pull request when none
5337
+ # is open, set it `running`, and name it once on the source issue. Always exit 0.
5338
+ verb_open() {
5339
+ local tmp made_tmp="" file
5340
+ if ! forge_on; then
5341
+ echo "remote-run.sh: open: the forge coupling is off (forge github and execution.target github-actions); nothing opened"
5342
+ exit "$EXIT_OK"
5343
+ fi
5344
+ forge_repo_var || exit "$EXIT_OK"
5345
+ forge_fetch_branch "$branch"
5346
+ forge_issue_var "$branch" || FORGE_ISSUE=""
5347
+ if ! forge_pr_var "$branch"; then
5348
+ echo "::warning::remote-run.sh: open: whether $branch already has a pull request could not be read ($GH_ERR); none opened"
5349
+ exit "$EXIT_OK"
5350
+ fi
5351
+ if [ -n "$FORGE_PR" ]; then
5352
+ echo "remote-run.sh: open: $branch already has pull request #$FORGE_PR; none opened"
5353
+ exit "$EXIT_OK"
5354
+ fi
5355
+ if ! forge_open_pr "$branch"; then
5356
+ echo "::warning::remote-run.sh: open: the pull request of $branch could not be opened: $OPEN_ERR. The run goes on and its comments reach its issue; deliver tries again when the run completes"
5357
+ exit "$EXIT_OK"
5358
+ fi
5359
+ echo "remote-run.sh: open: opened pull request #$FORGE_PR for $branch"
5360
+ forge_set_state "$FORGE_PR" running || :
5361
+ if [ -z "$FORGE_ISSUE" ]; then
5362
+ echo "remote-run.sh: open: $branch was started from no issue; nothing posted"
5363
+ exit "$EXIT_OK"
5364
+ fi
5365
+ tmp="${RUNNER_TEMP-}"
5366
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5367
+ tmp=$(mktemp -d) || tmp=""
5368
+ made_tmp="$tmp"
5369
+ fi
5370
+ if [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-open-comment.XXXXXX"); then
4348
5371
  {
4349
- printf '%s\n' "$text"
5372
+ if [ "$FORGE_PR_DRAFT" = false ]; then
5373
+ printf 'The harness run on `%s` opened its pull request #%s, not as a draft — this repository'"'"'s plan may not offer drafts: %s\n' \
5374
+ "$branch" "$FORGE_PR" "$FORGE_PR_URL"
5375
+ else
5376
+ printf 'The harness run on `%s` opened its draft pull request #%s: %s\n' "$branch" "$FORGE_PR" "$FORGE_PR_URL"
5377
+ fi
5378
+ printf '\nThe run'"'"'s questions, lifecycle comments and progress are posted there from now on; `%s` commands keep working on this issue.\n' \
5379
+ "$COMMAND_HANDLE"
4350
5380
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4351
5381
  } >"$file"
4352
- forge_comment "$target" completed "$branch" "$file" || :
5382
+ forge_comment "$FORGE_ISSUE" opened "$branch" "$file" || :
4353
5383
  rm -f "$file"
4354
5384
  else
4355
- echo "remote-run.sh: deliver: cannot create the comment file for #$target; no comment posted" >&2
5385
+ echo "remote-run.sh: open: cannot create the comment file for #$FORGE_ISSUE; no comment posted" >&2
4356
5386
  fi
4357
5387
  [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4358
-
4359
- [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
4360
- [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
4361
- echo "remote-run.sh: deliver: completed on $branch reported${target:+ on #$target}"
5388
+ echo "remote-run.sh: open: pull request #$FORGE_PR named on #$FORGE_ISSUE"
4362
5389
  exit "$EXIT_OK"
4363
5390
  }
4364
5391
 
@@ -4385,8 +5412,13 @@ control_cleanup() {
4385
5412
  return 0
4386
5413
  }
4387
5414
 
4388
- # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
4389
- # 1, after an `::error::` line, when it cannot be posted.
5415
+ # The engine a dispatch child named, set only once that child exited 0; a
5416
+ # non-empty value adds ` engine=<engine>` to every later reply's marker.
5417
+ CONTROL_REPLY_ENGINE=""
5418
+
5419
+ # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment,
5420
+ # its marker carrying CONTROL_REPLY_ENGINE; 1, after an `::error::` line, when
5421
+ # it cannot be posted.
4390
5422
  control_post() {
4391
5423
  local text="$1" file status=0
4392
5424
  if ! forge_repo_var; then
@@ -4401,7 +5433,7 @@ control_post() {
4401
5433
  printf '%s\n' "$text"
4402
5434
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4403
5435
  } >"$file"
4404
- if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
5436
+ if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file" "" "$CONTROL_REPLY_ENGINE"; then
4405
5437
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4406
5438
  status=1
4407
5439
  fi
@@ -4538,11 +5570,47 @@ control_stopped_refuse() {
4538
5570
  esac
4539
5571
  }
4540
5572
 
4541
- # control_check_branch <branch> — the refusals both paths share, in order: a
4542
- # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4543
- # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
5573
+ # control_run_in_flight <branch> — 0 when a run titled exactly
5574
+ # `harness run <branch>` is queued, in_progress, waiting, requested or
5575
+ # pending; 1 when none is; 2 with GH_ERR set when the listing failed or was
5576
+ # not a JSON array. Passes <branch> explicitly rather than reading the global
5577
+ # `branch` list_runs reads, and leaves GH_OUT as it found it.
5578
+ control_run_in_flight() {
5579
+ local saved="$GH_OUT" verdict status=0
5580
+ if ! gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$1" \
5581
+ --json displayTitle,status --limit "$RUN_LIST_LIMIT"; then
5582
+ GH_OUT="$saved"
5583
+ return 2
5584
+ fi
5585
+ verdict=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $1" '
5586
+ if type != "array" then error("not an array") else
5587
+ if any(.[]; .displayTitle == $t
5588
+ and (.status == "queued" or .status == "in_progress" or .status == "waiting"
5589
+ or .status == "requested" or .status == "pending"))
5590
+ then "yes" else "no" end
5591
+ end' 2>/dev/null) || verdict=""
5592
+ GH_OUT="$saved"
5593
+ case "$verdict" in
5594
+ yes) status=0 ;;
5595
+ no) status=1 ;;
5596
+ *) GH_ERR="its run list is not the expected JSON"; status=2 ;;
5597
+ esac
5598
+ return "$status"
5599
+ }
5600
+
5601
+ # control_check_branch <branch> [started] — the refusals every path shares, in
5602
+ # order: a branch not answered 1 by hr_branch_is_protected, then (after a
5603
+ # fetch) one that is not a harness branch. A branch passes with its task
5604
+ # prompt or the flow-progress ledger on its origin tip (`forge_recognised`);
5605
+ # or, with `started` (passed only by
5606
+ # control_branch_from_issue, whose issue's genuine `started` marker names it),
5607
+ # while it still exists on origin — gone is refused as deleted, a failed
5608
+ # existence check names the read, and no run list is read; or with a
5609
+ # `harness run <branch>` run in flight. Otherwise the refusal reads "neither a
5610
+ # task prompt nor a ledger, and no `harness run` in flight", and a failed listing names the read. Each
5611
+ # refusal is a reply and exit 2, or 3 for a failed read.
4544
5612
  control_check_branch() {
4545
- local b="$1" protected=0
5613
+ local b="$1" protected=0 exists=0 inflight=0
4546
5614
  if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
4547
5615
  control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
4548
5616
  fi
@@ -4556,8 +5624,25 @@ control_check_branch() {
4556
5624
  esac
4557
5625
  forge_fetch_branch "$b"
4558
5626
  if ! forge_recognised "$b"; then
4559
- control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
4560
- "Only a branch a harness run works on can be commanded."
5627
+ if [ "${2-}" = started ]; then
5628
+ remote_branch_exists "$b" || exists=$?
5629
+ case "$exists" in
5630
+ 0) ;;
5631
+ 1) control_refuse "$EXIT_REFUSED" "\`$b\` no longer exists on origin, so its run cannot be resumed or commanded" \
5632
+ "Start a new run from a new issue or the Run workflow form." ;;
5633
+ *) control_refuse "$EXIT_GH" "whether \`$b\` still exists on origin could not be read ($REMOTE_BRANCH_ERR)" \
5634
+ "Comment again to retry." ;;
5635
+ esac
5636
+ else
5637
+ control_run_in_flight "$b" || inflight=$?
5638
+ case "$inflight" in
5639
+ 0) ;;
5640
+ 1) control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries neither a task prompt nor a flow-progress ledger, and no \`harness run $b\` run is queued or in progress" \
5641
+ "Only a branch a harness run works on can be commanded." ;;
5642
+ *) control_refuse "$EXIT_GH" "whether \`$b\` has a harness run in flight could not be read ($GH_ERR)" \
5643
+ "Comment again to retry." ;;
5644
+ esac
5645
+ fi
4561
5646
  fi
4562
5647
  CONTROL_BRANCH="$b"
4563
5648
  }
@@ -4633,7 +5718,8 @@ control_issue_branch_var() {
4633
5718
  }
4634
5719
 
4635
5720
  # control_branch_from_issue <number> — control_issue_branch_var, its failures
4636
- # refused, then control_check_branch's refusals.
5721
+ # refused, then control_check_branch with `started`: the verified marker makes
5722
+ # the branch a harness branch while it still exists on origin.
4637
5723
  control_branch_from_issue() {
4638
5724
  if ! control_issue_branch_var "$1"; then
4639
5725
  if [ -n "$ISSUE_BRANCH_ERR" ]; then
@@ -4642,7 +5728,7 @@ control_branch_from_issue() {
4642
5728
  control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4643
5729
  "Comment on the pull request of the run's branch instead."
4644
5730
  fi
4645
- control_check_branch "$ISSUE_BRANCH"
5731
+ control_check_branch "$ISSUE_BRANCH" started
4646
5732
  }
4647
5733
 
4648
5734
  # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
@@ -4677,6 +5763,7 @@ control_resume_dispatch() {
4677
5763
  *) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
4678
5764
  esac
4679
5765
  status="$EXIT_OK"
5766
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
4680
5767
  control_post "$done_text" || status="$EXIT_GH"
4681
5768
  # The job posts its own `resumed` comment; the labels say `running` now.
4682
5769
  forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
@@ -4799,6 +5886,26 @@ control_ledger_next_var() {
4799
5886
  return 0
4800
5887
  }
4801
5888
 
5889
+ # control_round_newer_than_ledger — whether origin's tip of CONTROL_BRANCH
5890
+ # carries a user-review round commit newer than the ledger's last change: the
5891
+ # round's job writes its own ledger only after that commit. 0 when it does; 1
5892
+ # otherwise, a failed git read included. Subjects are compared as whole lines.
5893
+ control_round_newer_than_ledger() {
5894
+ local state_rel ledger_sha subjects subject line
5895
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
5896
+ [ -n "$state_rel" ] || return 1
5897
+ ledger_sha=$(git -C "$root" log -1 --format=%H "refs/remotes/origin/$CONTROL_BRANCH" \
5898
+ -- "${state_rel%/}/flow_progress/${CONTROL_BRANCH}_progress.md" 2>/dev/null) || return 1
5899
+ [ -n "$ledger_sha" ] || return 1
5900
+ subjects=$(git -C "$root" log --format=%s "$ledger_sha..refs/remotes/origin/$CONTROL_BRANCH" 2>/dev/null) \
5901
+ || return 1
5902
+ subject=$(hr_user_review_subject "$CONTROL_BRANCH")
5903
+ while IFS= read -r line; do
5904
+ [ "$line" != "$subject" ] || return 0
5905
+ done <<<"$subjects"
5906
+ return 1
5907
+ }
5908
+
4802
5909
  # control_status — the read-only reply: the state, the next ledger entry, the
4803
5910
  # open questions and the latest run. Sets no label and dispatches nothing.
4804
5911
  control_status() {
@@ -4824,6 +5931,10 @@ control_status() {
4824
5931
  if control_ledger_next_var; then
4825
5932
  if [ -n "$LEDGER_NEXT" ]; then
4826
5933
  text="$text${para}Next in the flow-progress ledger: $LEDGER_NEXT${LEDGER_SECTION:+ (under \`$LEDGER_SECTION\`)}"
5934
+ elif control_round_newer_than_ledger; then
5935
+ text="$text${para}A user-review round has started on \`$CONTROL_BRANCH\`, and its flow-progress ledger is not written yet."
5936
+ elif [ "$CS_WORD" = running ]; then
5937
+ 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."
4827
5938
  else
4828
5939
  text="$text${para}Every entry of the flow-progress ledger is ticked."
4829
5940
  fi
@@ -4955,6 +6066,7 @@ control_answer() {
4955
6066
  control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
4956
6067
  "Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
4957
6068
  esac
6069
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
4958
6070
 
4959
6071
  cmds=""
4960
6072
  for v in $CS_OPEN; do
@@ -5006,7 +6118,9 @@ control_review_story() {
5006
6118
  # `branch`'s pull request <pr_number>, shared by `control` and `collect`: every
5007
6119
  # submitted review (never `PENDING`) that requests changes or carries a
5008
6120
  # non-blank body, and every inline comment, no earlier round consumed, by
5009
- # every author `authorise_actor` accepts. What earlier rounds consumed is
6121
+ # every author `authorise_actor` accepts, the `HARNESS_RUN_ACTORS` allow-list
6122
+ # included; a refused author's items are dropped with one line, while a
6123
+ # failed permission call (status 4) fails the collection. What earlier rounds consumed is
5010
6124
  # read from the marker lines of their files on origin's tip; with none marked,
5011
6125
  # the boundary is the committer time of the newest round file. A comment
5012
6126
  # belonging to a pending review is pending whatever its `created_at`: a draft
@@ -5028,18 +6142,27 @@ RC_ERR=""
5028
6142
  # origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
5029
6143
  # recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
5030
6144
  # highest-numbered marked round's `collected_at`, empty when none is marked),
5031
- # RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
5032
- # 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
5033
- # on its own, to name the round a review in flight is already part of.
6145
+ # RC_MARKED_N and RC_MARKED_C (that round's number, 0 when none, and its
6146
+ # `comments=` ids as written), RC_NEWEST_N (the highest round number of any
6147
+ # round file, marked or not, 0 when none), RC_EVENT_ROUND (the round recording
6148
+ # <event_review_id>), and RC_STATE_REL. 4 with RC_ERR when a round cannot be
6149
+ # listed or read. `control` also calls it on its own, to name the round a
6150
+ # review in flight is already part of, and `deliver` to find a round's threads.
5034
6151
  RC_SEEN_R=","
5035
6152
  RC_SEEN_C=","
5036
6153
  RC_MARKED_AT=""
6154
+ RC_MARKED_N=0
6155
+ RC_MARKED_C=""
6156
+ RC_NEWEST_N=0
5037
6157
  RC_STATE_REL=""
5038
6158
  round_markers_read() {
5039
- local event_id="${1-}" names name path n line marked_max=0
6159
+ local event_id="${1-}" names name path n line
5040
6160
  RC_SEEN_R=","
5041
6161
  RC_SEEN_C=","
5042
6162
  RC_MARKED_AT=""
6163
+ RC_MARKED_N=0
6164
+ RC_MARKED_C=""
6165
+ RC_NEWEST_N=0
5043
6166
  RC_EVENT_ROUND=""
5044
6167
  RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
5045
6168
  RC_STATE_REL="${RC_STATE_REL%/}"
@@ -5057,6 +6180,7 @@ round_markers_read() {
5057
6180
  [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
5058
6181
  n="${BASH_REMATCH[3]:-1}"
5059
6182
  n=$((10#$n))
6183
+ [ "$n" -le "$RC_NEWEST_N" ] || RC_NEWEST_N="$n"
5060
6184
  if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
5061
6185
  RC_ERR="the previous round \`$path\` could not be read"
5062
6186
  return 4
@@ -5066,9 +6190,10 @@ round_markers_read() {
5066
6190
  [[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
5067
6191
  RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
5068
6192
  RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
5069
- if [ "$n" -gt "$marked_max" ]; then
5070
- marked_max="$n"
6193
+ if [ "$n" -gt "$RC_MARKED_N" ]; then
6194
+ RC_MARKED_N="$n"
5071
6195
  RC_MARKED_AT="${BASH_REMATCH[1]}"
6196
+ RC_MARKED_C="${BASH_REMATCH[3]}"
5072
6197
  fi
5073
6198
  case ",${BASH_REMATCH[2]}," in
5074
6199
  *",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
@@ -5242,12 +6367,16 @@ REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the
5242
6367
  # control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
5243
6368
  # counted as settled, run in a command substitution so that its exit on a
5244
6369
  # failed read reaches this process as a status, not as an exit with no reply:
5245
- # BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
5246
- # failed read.
6370
+ # BS_SETTLED, BS_STATE and BS_REASON, and RS_NOT_STARTED, RS_RUN_ID, RS_ENGINE
6371
+ # and RS_DETAIL as BS_NOT_STARTED, BS_RUN_ID, BS_ENGINE and BS_DETAIL; 1 with
6372
+ # BS_ERR, its last stderr line, on a failed read. BS_DETAIL is read last, so a
6373
+ # `|` inside it shifts no other field.
5247
6374
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6375
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5248
6376
  control_settled_var() {
5249
6377
  local dir errfile line status=0
5250
6378
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6379
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5251
6380
  if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
5252
6381
  BS_ERR="a state directory could not be created under '$control_tmp'"
5253
6382
  return 1
@@ -5256,14 +6385,15 @@ control_settled_var() {
5256
6385
  control_dirs="$control_dirs $dir $errfile"
5257
6386
  branch="$CONTROL_BRANCH"
5258
6387
  line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
5259
- && printf '%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON") || status=$?
6388
+ && printf '%s|%s|%s|%s|%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON" \
6389
+ "$RS_NOT_STARTED" "$RS_RUN_ID" "$RS_ENGINE" "$RS_DETAIL") || status=$?
5260
6390
  cat "$errfile" >&2 2>/dev/null || :
5261
6391
  if [ "$status" -ne 0 ] || [ -z "$line" ]; then
5262
6392
  BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
5263
6393
  [ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
5264
6394
  return 1
5265
6395
  fi
5266
- IFS='|' read -r BS_SETTLED BS_STATE BS_REASON <<<"$line"
6396
+ IFS='|' read -r BS_SETTLED BS_STATE BS_REASON BS_NOT_STARTED BS_RUN_ID BS_ENGINE BS_DETAIL <<<"$line"
5267
6397
  return 0
5268
6398
  }
5269
6399
 
@@ -5551,7 +6681,8 @@ control_close() {
5551
6681
  fi
5552
6682
  forge_on || control_close_ignore "the default branch's harness.config.json does not turn run control on"
5553
6683
  if [ "$CLOSE_KIND" = deleted ]; then
5554
- # Deleting a branch already needs write access; only a bot is screened.
6684
+ # Deleting a branch already needs write access; only a bot is screened,
6685
+ # never HARNESS_RUN_ACTORS (the header, THE CLOSE gate 3).
5555
6686
  if [ "$CONTROL_SENDER_TYPE" = Bot ] && ! trigger_bot_listed "$CONTROL_ACTOR"; then
5556
6687
  control_close_ignore "@$CONTROL_ACTOR is a bot not listed in HARNESS_TRIGGER_ALLOWED_BOTS"
5557
6688
  fi
@@ -5608,7 +6739,7 @@ control_close() {
5608
6739
  fi
5609
6740
 
5610
6741
  # 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.
6742
+ # its task prompt or ledger, and a listed `harness run <b>` run is what proves a harness run.
5612
6743
  if ! control_state_var "$b"; then
5613
6744
  echo "::error::remote-run.sh: control: the state of the run on \`$b\` could not be read ($CS_ERR)"
5614
6745
  exit "$EXIT_GH"
@@ -5695,11 +6826,16 @@ verb_control() {
5695
6826
  "Set both keys on the default branch, then comment again."
5696
6827
  fi
5697
6828
 
6829
+ if ! rerun_actor_listed; then
6830
+ control_refuse "$EXIT_REFUSED" "${RUN_ACTORS_WHY%.}" \
6831
+ "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
6832
+ fi
6833
+
5698
6834
  status=0
5699
6835
  authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5700
6836
  if [ "$status" -ne 0 ]; then
5701
6837
  control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
5702
- "Only a collaborator with write, maintain or admin access, or a bot listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
6838
+ "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."
5703
6839
  fi
5704
6840
 
5705
6841
  if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
@@ -5755,7 +6891,7 @@ collect_notify() {
5755
6891
  }
5756
6892
 
5757
6893
  verb_collect() {
5758
- local status=0 dir file out
6894
+ local status=0 dir file out reason route way
5759
6895
  if ! forge_on; then
5760
6896
  echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
5761
6897
  exit "$EXIT_OK"
@@ -5780,17 +6916,7 @@ verb_collect() {
5780
6916
  exit "$EXIT_OK" ;;
5781
6917
  esac
5782
6918
 
5783
- if [ -n "$pr_arg" ]; then
5784
- FORGE_PR="$pr_arg"
5785
- elif ! forge_pr_var "$branch"; then
5786
- echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
5787
- exit "$EXIT_OK"
5788
- fi
5789
- if [ -z "$FORGE_PR" ]; then
5790
- echo "remote-run.sh: collect: no open pull request; nothing to collect"
5791
- exit "$EXIT_OK"
5792
- fi
5793
-
6919
+ # Read before the pull request is required: a first run has none.
5794
6920
  control_tmp="${RUNNER_TEMP-}"
5795
6921
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5796
6922
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
@@ -5803,6 +6929,37 @@ verb_collect() {
5803
6929
  echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
5804
6930
  exit "$EXIT_OK"
5805
6931
  fi
6932
+ if [ "$BS_NOT_STARTED" = 1 ]; then
6933
+ if [ -n "${GITHUB_RUN_ID-}" ] && [ "$BS_RUN_ID" != "$GITHUB_RUN_ID" ]; then
6934
+ echo "remote-run.sh: collect: run $BS_RUN_ID of $branch never started; its own collect reports it"
6935
+ exit "$EXIT_OK"
6936
+ fi
6937
+ reason="${BS_DETAIL#"GitHub did not start the job of run $BS_RUN_ID ("}"
6938
+ if [ "$reason" = "$BS_DETAIL" ]; then reason=""; else reason="${reason%"): "*}"; fi
6939
+ REPORT_NOT_STARTED_STATE="$BS_STATE"
6940
+ REPORT_NOT_STARTED_ENGINE="$BS_ENGINE"
6941
+ route=$(hr_github_resume_route "$branch" "${BS_ENGINE:-<task, user_review or docs: the one the run was started with>}")
6942
+ if [ "$BS_STATE" = paused ]; then
6943
+ way="Run $RESUME_HINT $branch to start it again; $route."
6944
+ else
6945
+ way="Start it again ${route#or }."
6946
+ fi
6947
+ notify not_started "$branch" "$BS_DETAIL. $way" "$reason"
6948
+ # The reviews stay for the resumed run's own end.
6949
+ echo "remote-run.sh: collect: run ${BS_RUN_ID} of $branch never started; reported, and no round collected"
6950
+ exit "$EXIT_OK"
6951
+ fi
6952
+
6953
+ if [ -n "$pr_arg" ]; then
6954
+ FORGE_PR="$pr_arg"
6955
+ elif ! forge_pr_var "$branch"; then
6956
+ echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
6957
+ exit "$EXIT_OK"
6958
+ fi
6959
+ if [ -z "$FORGE_PR" ]; then
6960
+ echo "remote-run.sh: collect: no open pull request; nothing to collect"
6961
+ exit "$EXIT_OK"
6962
+ fi
5806
6963
  if [ "$BS_SETTLED" != 1 ]; then
5807
6964
  echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
5808
6965
  exit "$EXIT_OK"
@@ -5839,7 +6996,11 @@ verb_collect() {
5839
6996
  echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
5840
6997
  exit "$EXIT_OK"
5841
6998
  fi
5842
- collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
6999
+ if [ -n "${GITHUB_RUN_ID-}" ]; then
7000
+ collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request. To retry, re-run this run's \`collect\` job (no new review is needed), or submit a review requesting changes."
7001
+ else
7002
+ collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
7003
+ fi
5843
7004
  echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
5844
7005
  exit "$EXIT_OK"
5845
7006
  }
@@ -5923,6 +7084,7 @@ case "$verb" in
5923
7084
  list) verb_list ;;
5924
7085
  discard) verb_discard ;;
5925
7086
  report) verb_report ;;
7087
+ open) verb_open ;;
5926
7088
  deliver) verb_deliver ;;
5927
7089
  collect) verb_collect ;;
5928
7090
  control) verb_control ;;