autonomous-sdlc-harness 0.6.2 → 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.
@@ -285,17 +286,35 @@
285
286
  # as `trigger` is, and outside the sending-verb gate: it does nothing, with one
286
287
  # line, unless `hr_forge` is `github` and `hr_execution_target` is
287
288
  # `github-actions`. The comment goes to the open same-repository pull request
288
- # whose head is <branch> when origin's <branch> carries
289
- # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
290
- # 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
291
292
  # task prompt, else nowhere (`stop`'s `--pr` and `--branch-gone` change this
292
293
  # for its own report: its paragraph). The label `STATE_LABEL_PREFIX<state>` replaces any
293
294
  # other state label on that issue and that pull request, each when known; the
294
295
  # label is a view, and the run list stays the authority. The state map:
295
296
  # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
296
- # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
297
- # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round` and
298
- # `failed` — posts nothing and sets no label when `remote_branch_stopped`,
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`,
299
318
  # asked afresh, finds the branch stopped, so a job a stop overtook never
300
319
  # overwrites `stopped`; a failed listing reports anyway, and `stopped` is never
301
320
  # withheld. `completed` (deliver's) and `launched` (the trigger's
@@ -319,36 +338,127 @@
319
338
  # comment and its answer are public, as the artifact already is
320
339
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
321
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.
322
360
  # It never fails its caller: every problem is one line and exit 0.
323
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
+ #
324
383
  # `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
325
384
  # job's own `push-branch.sh`, which opens no pull request. Job-side for its
326
385
  # root and self-gated, as `report` is (`forge_on`, one line when off). Anything
327
386
  # but `status: completed` in <bundle_dir>/status.json, or no status.json, is
328
- # one line and nothing sent. Otherwise, by the forge surface: an open
329
- # same-repository pull request whose head is <branch> is reused and no second
330
- # one is opened; else a draft is created against `defaultBranch`, titled from
331
- # the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
332
- # <branch>), whose body names the issue as `Started from #<n>.` — a plain
333
- # mention, never a closing keyword — states what a review requesting changes
334
- # and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
335
- # marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
336
- # set, so the adopter's CI runs without an approval click; every other call
337
- # uses the job's token. A pull request so opened is authored by that token's
338
- # owner, who therefore cannot request changes on it: use a machine account's
339
- # token, another reviewer, or a local `branch-user-review` round. A create
340
- # refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
341
- # Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
342
- # without `--draft`, and a second failure is named in the comment with the
343
- # branch's compare URL. A pull-request lookup that fails opens nothing. Then
344
- # one `completed` comment: on the issue naming the new pull request's URL; on
345
- # the pull request when there is no issue, or when it existed before this run
346
- # (saying the round finished); on the issue alone when none could be opened;
347
- # nowhere when neither is known. It names reviewing and requesting changes as
348
- # the next action, and with `phases.qa` true the local `branch-qa-test` still
349
- # owed. Then `sdlc-harness: done` on the issue and the pull request, each when
350
- # known. It writes at most one pull request, one comment and those labels, and
351
- # 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.
352
462
  #
353
463
  # `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
354
464
  # one step of the run workflow's `collect` job, which follows its `run` job, so
@@ -356,19 +466,27 @@
356
466
  # Job-side for its root and self-gated, as `report` and `deliver` are
357
467
  # (`forge_on`, one line when off). In order, each stop one line and exit 0:
358
468
  # `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
359
- # or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
360
- # 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
361
477
  # branch in flight — a newer run listed, or this run ending `parked`,
362
478
  # `park_loop` or `paused` (a budget chain's included), whose own end collects
363
- # next — or failing; `round_collect` finding no review requesting changes
479
+ # next; `round_collect` finding no review requesting changes
364
480
  # pending (inline comments alone start no round, as *Comment* starts none; they
365
481
  # ride along in the next), or failing, a `::warning::` line. Otherwise `review
366
482
  # <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
367
483
  # <pull request url>` runs as a child, as `control` runs it, placing,
368
484
  # dispatching and reporting the round. A child that exits non-zero gets exactly
369
485
  # one comment on the pull request, with the `reply` marker, naming its last
370
- # stderr line and saying the reviews stay there and that submitting a review
371
- # 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
372
490
  # caller: every outcome but a usage error is exit 0.
373
491
  #
374
492
  # `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
@@ -412,8 +530,20 @@
412
530
  # marker quoted mid-body, in a comment not opening with that sentence, or by
413
531
  # anyone else, is never trusted; none is a refusal. Both paths then pass
414
532
  # `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
415
- # for is refused, then it is fetched, and one whose origin tip carries no
416
- # 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.
417
547
  # THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
418
548
  # `running` sends `pause <branch>` as a child and replies that the run yields at
419
549
  # its next clean checkpoint; any other state is a refusal naming it. `stop`:
@@ -428,9 +558,11 @@
428
558
  # accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
429
559
  # any other state is a refusal naming it. Each sends the local relay's dispatch,
430
560
  # `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
431
- # `clear` with `park_loop_clear` set, and no other command sets it. An empty
432
- # engine is refused, never guessed (the `engine` input defaults to `task`),
433
- # 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
434
566
  # and pull request; on 2 a refusal and exit 2. `answer`: the first line is
435
567
  # `answer <n>` and the answer every line below it, a trailing CR stripped from
436
568
  # each line and its bytes otherwise unchanged; text after <n> on the first line
@@ -555,8 +687,10 @@
555
687
  # submitting a review requesting changes retries now.
556
688
  # A child's failure is a reply
557
689
  # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
558
- # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
559
- # `@<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>`.
560
694
  # THE CLOSE. Whatever the run's phase, it is stopped when its issue is closed,
561
695
  # its pull request closed or merged, or its branch deleted; the routing is by
562
696
  # event name, never by verb, so a comment naming `close` is not one. `issues`
@@ -585,8 +719,8 @@
585
719
  # answers 1): GitHub closed the pull request because the branch was
586
720
  # deleted, and the `delete` event's job stops the run; an `ls-remote` that
587
721
  # cannot answer is one line and proceeds
588
- # There is no ledger-at-tip check (`forge_recognised`): a merged or deleted
589
- # branch may no longer carry one, and a listed `harness run <b>` run is the
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
590
724
  # proof. The state is read by `control_state_var`: only `running`, `parked`,
591
725
  # `park_loop` and `paused` are acted on; `completed`, `failed` and `none` are
592
726
  # `left alone: the run on <b> is <state>`, and one `remote_branch_stopped`
@@ -683,7 +817,9 @@
683
817
  # HARNESS_MAX_CHAIN the automatic-dispatch limit; `24` when empty. Not a
684
818
  # non-negative integer: nothing is dispatched
685
819
  # HARNESS_POLL_MAX_DISPATCH_FAILURES `poll` only: failed re-dispatches of
686
- # 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
687
823
  # HARNESS_POLL_GIVE_UP_AFTER_MINUTES `poll` only: minutes after a run's
688
824
  # `usage_resume_at` past which a failed re-dispatch gives
689
825
  # up; `360` when empty. Either one not a non-negative
@@ -694,7 +830,9 @@
694
830
  # GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
695
831
  # Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
696
832
  # `failed`, and each is then reported as `report` reports that event, with a
697
- # 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>
698
836
  # --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
699
837
  #
700
838
  # `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
@@ -741,7 +879,8 @@
741
879
  # than `GITHUB_RUN_ID` (bounded) carrying an unexpired `harness-poll-state`
742
880
  # artifact is downloaded to `<state_dir>/autonomous_logs/poll_state/previous/`;
743
881
  # any failure to find or read it is one line and an empty state. Its
744
- # `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
745
884
  # entry whose `run_id` is not the branch's newest `harness run` run is dropped,
746
885
  # so a new run restarts the count. `poll` writes the state to `poll_state/
747
886
  # current/` on every exit, `HARNESS_REMOTE_STOP` included, and the poller
@@ -756,9 +895,16 @@
756
895
  # download that fails for it is one line and waiting. For a `completed` run,
757
896
  # skipped, not waiting: a stopped branch, a branch absent on origin (its state
758
897
  # entry dropped, so a later branch of the name starts clean; an `ls-remote`
759
- # that cannot answer is one line and proceeds), a bundle that cannot be
760
- # downloaded (one line), and anything but `status: paused` / `pause_reason: usage` with an
761
- # 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
762
908
  # (reset passed): re-dispatched under the same chain limit — a refusal is one
763
909
  # `failed` and not waiting; a success drops the branch's state entry. A
764
910
  # dispatch that fails counts one more failure for that run and is still
@@ -783,9 +929,12 @@
783
929
  # `pause-requested` lists the branch's runs and exits 0 when one whose
784
930
  # `displayTitle` is exactly `harness pause <branch>` has a `createdAt` at or
785
931
  # after <since_epoch> — AT, because `createdAt` has one-second resolution and
786
- # the caller takes <since_epoch> just before the query it will next start
787
- # from, so a pause created later in that same second is still seen; seeing one
788
- # 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
789
938
  # `gh run view <run_id> --json createdAt` as an epoch second.
790
939
  #
791
940
  # `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
@@ -840,12 +989,17 @@
840
989
  # `engine` when the bundle names `task`, `user_review` or `docs`. A
841
990
  # `mirror` restore places no planning draft
842
991
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
843
- # 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).
844
994
  # `paused` / `killed`, `remote_run_id` / `remote_run_url` re-pointed at
845
- # 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
846
998
  # 5. no bundle in any run and an empty `remote_run_id`: `failed`. Not
847
999
  # `paused`: with no bundle anywhere a pause resume has nothing to restore,
848
- # 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)`
849
1003
  #
850
1004
  # THE `killed` AND `expired` MAPPINGS. A finished run whose bundle still says
851
1005
  # `running` (a kill, a timeout with no chain left) syncs as `status: paused`,
@@ -854,7 +1008,16 @@
854
1008
  # never carries either, because `sync` derives them from the run and its
855
1009
  # artifact list, never from a bundle. Both are `paused` rather than `failed`
856
1010
  # because a `failed` record has no resume path, while the ledger on the branch
857
- # 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`.
858
1021
  #
859
1022
  # THE WORKFLOW INPUT CONTRACT (the workflow template declares the same inputs):
860
1023
  #
@@ -919,37 +1082,55 @@
919
1082
  # listing that `trigger` and `review` make. Only `start` and `review` push, and
920
1083
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
921
1084
  # the prompt committed on `origin/<branch>`, through a working copy and a local
922
- # branch it removes before it returns; `review`'s are the round committed on
923
- # `origin/<branch>`, through the record's mirror or a copy and a local branch
924
- # it removes, the record's `status` / `engine`, the bundle download
925
- # 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`
926
1096
  # writes the record's `status`, `resumed_at` and `resume_kind` in one write
927
1097
  # when the main checkout's registry file exists and holds a record with
928
1098
  # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
929
1099
  # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
930
1100
  # for a dispatch event a block in the step summary. `report` writes its comment
931
1101
  # file under `RUNNER_TEMP` (removed), one comment and the state labels on the
932
- # 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
933
1105
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
934
1106
  # comment and the state labels. `control` writes its reply file and its
935
1107
  # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
936
- # what the child verb it runs writes. `collect` writes its round file and its
937
- # settledness directory under `RUNNER_TEMP` (removed), at most one comment on
938
- # 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
939
1116
  # `report`'s writes for each event they report. Every other verb's only writes are the
940
1117
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
941
1118
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
942
1119
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
943
- # `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
944
1123
  # `restore`, that download directory, the job restore (the planning drafts
945
1124
  # among it), `answer_<n>.md` and the
946
1125
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
947
1126
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
948
1127
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
949
- # and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
950
- # <dir> only. `pause-requested`,
951
- # `run-created-at`, `list` and `status` write nothing; a no-record `status`
952
- # 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.
953
1134
  #
954
1135
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
955
1136
  # rename there is an edit here, byte for byte:
@@ -1139,7 +1320,9 @@
1139
1320
  # the job's reads: a `run list` answer whose run has `displayTitle` `harness
1140
1321
  # pause feat_x` and `createdAt` `2026-01-01T00:00:10Z` (epoch 1767225610):
1141
1322
  # pause-requested bash scripts/remote-run.sh pause-requested feat_x 1767225600
1142
- # -> 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
1143
1326
  # run-created-at a stub answering `run view 42 --json createdAt` with
1144
1327
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
1145
1328
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
@@ -1178,15 +1361,45 @@
1178
1361
  # naming `@sdlc-harness resume`, then `api --method POST
1179
1362
  # repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
1180
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>`
1181
1371
  #
1182
1372
  # deliver needs report's setup, a stub answering the create with
1183
1373
  # https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
1184
1374
  # carries schema "1" and status completed:
1185
1375
  # deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
1186
- # a `--draft` create with `--base main --head feat_x`, then one
1187
- # comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
1188
- # 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
1189
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
1190
1403
  #
1191
1404
  # collect needs deliver's setup, a story index on feat_x, a stub answering
1192
1405
  # `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
@@ -1274,6 +1487,11 @@ RUN_JOB_NAME='run'
1274
1487
  # How far before the previous round's `collected_at` `round_collect` lists
1275
1488
  # from, absorbing runner-clock skew; the id check drops what it re-lists.
1276
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
1277
1495
 
1278
1496
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
1279
1497
  # maximum payload for inputs is 65,535 characters."
@@ -1285,8 +1503,9 @@ EXIT_USAGE=1
1285
1503
  EXIT_REFUSED=2
1286
1504
  EXIT_GH=3
1287
1505
  EXIT_PLACEMENT=4
1288
- # pause-requested only: the read succeeded and found no pause.
1289
- 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
1290
1509
 
1291
1510
  usage() {
1292
1511
  echo "remote-run.sh: $1" >&2
@@ -1309,6 +1528,7 @@ usage() {
1309
1528
  echo " remote-run.sh list [--repo <root>]" >&2
1310
1529
  echo " remote-run.sh discard <dir> [--repo <root>]" >&2
1311
1530
  echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
1531
+ echo " remote-run.sh open <branch> [--repo <root>]" >&2
1312
1532
  echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1313
1533
  echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1314
1534
  echo " remote-run.sh control [--repo <root>]" >&2
@@ -1381,7 +1601,7 @@ verb=""
1381
1601
  verb="$1"
1382
1602
  shift
1383
1603
  case "$verb" in
1384
- 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) ;;
1385
1605
  *) usage "unknown verb '$verb'" ;;
1386
1606
  esac
1387
1607
 
@@ -1606,10 +1826,11 @@ fi
1606
1826
  # ---------------------------------------------------------------------------
1607
1827
 
1608
1828
  # setup_fail <message> — a configuration problem: exit 1, except for save,
1609
- # 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.
1610
1830
  setup_fail() {
1611
1831
  echo "remote-run.sh: $1" >&2
1612
- [ "$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"
1613
1834
  exit "$EXIT_USAGE"
1614
1835
  }
1615
1836
 
@@ -1617,8 +1838,8 @@ if [ -n "$repo_arg" ]; then
1617
1838
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
1618
1839
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
1619
1840
  || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
1620
- || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
1621
- || [ "$verb" = control ]; then
1841
+ || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = open ] || [ "$verb" = deliver ] \
1842
+ || [ "$verb" = collect ] || [ "$verb" = control ]; then
1622
1843
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
1623
1844
  else
1624
1845
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -1637,7 +1858,7 @@ case "$verb" in
1637
1858
  trigger|control)
1638
1859
  # Gates itself, after reading the event, so a refusal can still be commented.
1639
1860
  ;;
1640
- report|deliver|collect)
1861
+ report|open|deliver|collect)
1641
1862
  # Gates itself (`forge_on`) and exits 0 on every outcome.
1642
1863
  ;;
1643
1864
  status|sync)
@@ -1917,6 +2138,54 @@ expired_line() {
1917
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"
1918
2139
  }
1919
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
+
1920
2189
  set_or_fail() {
1921
2190
  hr_registry_set "$registry" "$branch" "$1" "$2" || {
1922
2191
  echo "remote-run.sh: writing $1 of $branch to '$registry' failed" >&2
@@ -2035,10 +2304,17 @@ sync_expired() {
2035
2304
  # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
2036
2305
  # existing for case 4. <finished> 1 reads the newest run as finished whatever
2037
2306
  # its `status`: `branch_settled_var` passes it once that run's `run` job has
2038
- # 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.
2039
2313
  RS_RUNS=""
2040
2314
  RS_RUN_ID=""
2041
2315
  RS_RUN_URL=""
2316
+ RS_RUN_CREATED_AT=""
2317
+ RS_NOT_STARTED=0
2042
2318
  RS_GH_STATUS=""
2043
2319
  RS_STATE=""
2044
2320
  RS_PAUSE_REASON=""
@@ -2052,7 +2328,8 @@ remote_state() {
2052
2328
  local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
2053
2329
  RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
2054
2330
  RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
2055
- 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
2056
2333
  RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
2057
2334
  newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
2058
2335
  if [ -z "$newest" ]; then
@@ -2062,6 +2339,7 @@ remote_state() {
2062
2339
  RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
2063
2340
  RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
2064
2341
  RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
2342
+ RS_RUN_CREATED_AT=$(printf '%s' "$newest" | jq -r '.createdAt // ""')
2065
2343
  if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
2066
2344
  RS_STATE=running
2067
2345
  return 0
@@ -2129,12 +2407,32 @@ remote_state() {
2129
2407
  RS_STATE=paused
2130
2408
  RS_PAUSE_REASON=killed
2131
2409
  RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
2132
- 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"
2133
2414
  fi
2134
2415
 
2135
- # Case 5 — no bundle in any run.
2136
- RS_STATE=failed
2137
- 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
2138
2436
  return 0
2139
2437
  }
2140
2438
 
@@ -2264,10 +2562,16 @@ verb_sync() {
2264
2562
  return 0
2265
2563
  fi
2266
2564
 
2267
- # 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.
2268
2567
  if [ "$RS_STATE" = paused ]; then
2269
- set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2270
- 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
2271
2575
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
2272
2576
  return 0
2273
2577
  fi
@@ -2521,12 +2825,9 @@ verb_save() {
2521
2825
  return 0
2522
2826
  }
2523
2827
 
2524
- # notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
2525
- # then `forge_report` of the same event; never fails. Every call site supplies
2526
- # both texts: <detail> is the push notification's, slash commands included;
2527
- # <forge_note> is the comment's, naming no slash command and no shell command,
2528
- # and states only what happened, since `forge_report` adds the next action.
2529
- 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() {
2530
2831
  if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
2531
2832
  HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
2532
2833
  export HARNESS_REPO_SLUG
@@ -2534,6 +2835,15 @@ notify() {
2534
2835
  bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
2535
2836
  || echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
2536
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"
2537
2847
  # stdin closed: `poll` calls this inside a loop reading its run list.
2538
2848
  forge_report "$1" "$2" "$4" </dev/null
2539
2849
  }
@@ -2730,7 +3040,10 @@ verb_continue() {
2730
3040
  }
2731
3041
 
2732
3042
  # POLL_STATE — the poller state carried between ticks, one object keyed by
2733
- # 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.
2734
3047
  POLL_STATE='{}'
2735
3048
  POLL_STATE_FILE_NAME='poll_state.json'
2736
3049
 
@@ -2738,9 +3051,21 @@ poll_state_get() {
2738
3051
  printf '%s' "$POLL_STATE" | jq -r --arg b "$1" --arg f "$2" '.[$b][$f] // "" | tostring'
2739
3052
  }
2740
3053
 
3054
+ # poll_state_put <branch> <run_id> <failures> <notified> — keeps the entry's
3055
+ # `download_failures` when its `run_id` is the same.
2741
3056
  poll_state_put() {
2742
- POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg f "$3" --arg n "$4" \
2743
- '.[$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}')
2744
3069
  }
2745
3070
 
2746
3071
  poll_state_drop() {
@@ -2859,7 +3184,7 @@ poll_usage_paused() {
2859
3184
  # With may_dispatch 0 nothing is sent and nothing notified: a due run that
2860
3185
  # would be dispatched counts as waiting, one that would be refused does not.
2861
3186
  poll_branch() {
2862
- 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
2863
3188
  if [ "$may_dispatch" -eq 1 ] && [ -n "$(poll_state_get "$branch" run_id)" ] \
2864
3189
  && [ "$(poll_state_get "$branch" run_id)" != "$id" ]; then
2865
3190
  poll_state_drop "$branch"
@@ -2894,10 +3219,40 @@ poll_branch() {
2894
3219
  echo "remote-run.sh: poll: $branch's run $id was already reported as not re-dispatchable; skipped"
2895
3220
  return 1
2896
3221
  fi
2897
- if ! poll_fetch "$id"; then
2898
- 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
2899
3251
  return 1
2900
3252
  fi
3253
+ if [ -n "$downloads" ] && [ "$may_dispatch" -eq 1 ]; then
3254
+ poll_state_downloads_put "$branch" "$id" ""
3255
+ fi
2901
3256
  poll_usage_paused || return 1
2902
3257
  at=$(hr_remote_status_get "$POLL_STATUS_FILE" usage_resume_at) || at=""
2903
3258
  case "$at" in
@@ -3131,8 +3486,12 @@ verb_start() {
3131
3486
  status=0
3132
3487
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
3133
3488
  [ "$status" -ne 1 ] || placement_fail "committing '$rel'"
3134
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3135
- || 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)"
3136
3495
  start_remove_copy
3137
3496
  trap - EXIT
3138
3497
 
@@ -3285,8 +3644,12 @@ NAMES
3285
3644
  status=0
3286
3645
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
3287
3646
  [ "$status" -eq 0 ] || review_fail "committing '$rel'"
3288
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3289
- || 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)"
3290
3653
  pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
3291
3654
  if [ "$use_mirror" -eq 0 ]; then
3292
3655
  start_remove_copy
@@ -3865,9 +4228,12 @@ The task is the dispatch's \`client_payload\` title and body. Sending the same d
3865
4228
  # never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
3866
4229
  #
3867
4230
  # THE TARGET RULE. A comment goes to the open same-repository pull request
3868
- # whose head is the branch when `forge_recognised` holds for that branch, else
3869
- # to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
3870
- # 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.
3871
4237
  #
3872
4238
  # THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
3873
4239
  # (`remote_state`); the harness overwrites any state label set by hand, and
@@ -3926,14 +4292,12 @@ forge_fetch_branch() {
3926
4292
  return 0
3927
4293
  }
3928
4294
 
3929
- # forge_marker <event> <branch> [<question>] — the one producer of a comment's
3930
- # 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 ` -->`.
3931
4298
  forge_marker() {
3932
- if [ -n "${3-}" ]; then
3933
- printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
3934
- else
3935
- printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
3936
- 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}"
3937
4301
  }
3938
4302
 
3939
4303
  # forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
@@ -4014,38 +4378,105 @@ forge_provenance_parse() {
4014
4378
  }
4015
4379
 
4016
4380
  # forge_recognised <branch> — the harness-branch test, read from committed
4017
- # 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.
4018
4383
  forge_recognised() {
4019
4384
  local state_rel
4020
4385
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
4021
4386
  [ -n "$state_rel" ] || return 1
4022
- 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
4023
4389
  }
4024
4390
 
4025
4391
  # forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
4026
- # <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.
4027
4395
  FORGE_PR=""
4396
+ FORGE_PR_DRAFT=""
4028
4397
  forge_pr_var() {
4398
+ local sel
4029
4399
  FORGE_PR=""
4030
- 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
4031
4402
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
4032
4403
  return 1
4033
4404
  fi
4034
- if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
4035
- 'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
4036
- 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
4037
4407
  GH_ERR="its pr list is not the expected JSON"
4038
4408
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
4039
4409
  return 1
4040
4410
  fi
4411
+ if [ -n "$sel" ]; then
4412
+ FORGE_PR="${sel%% *}"
4413
+ FORGE_PR_DRAFT="${sel#* }"
4414
+ fi
4415
+ return 0
4416
+ }
4417
+
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"
4041
4472
  return 0
4042
4473
  }
4043
4474
 
4044
- # forge_comment <number> <event> <branch> <body_file> [<question>] — append the
4045
- # marker to <body_file> and post it on issue or pull request <number>.
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>.
4046
4477
  forge_comment() {
4047
- local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
4048
- 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
4049
4480
  echo "remote-run.sh: cannot append the marker to '$file'" >&2
4050
4481
  return 1
4051
4482
  fi
@@ -4160,13 +4591,20 @@ forge_question_body() {
4160
4591
  # that is the target whatever its state; `gone`, the branch deleted on GitHub,
4161
4592
  # its issue read from the task prompt at <sha>. Every event but `stopped` is
4162
4593
  # withheld when the branch's newest `harness stop` run is newer than its newest
4163
- # `harness run` run, read from a fresh listing. Always 0.
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=""
4164
4600
  forge_report() {
4165
4601
  local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
4166
- 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=""
4167
4603
  case "$event" in
4168
4604
  parked|park_loop) state=parked ;;
4169
4605
  paused) state=paused ;;
4606
+ not_started)
4607
+ if [ "$REPORT_NOT_STARTED_STATE" = paused ]; then state=paused; else state=failed; fi ;;
4170
4608
  resumed) state=running ;;
4171
4609
  failed) state=failed ;;
4172
4610
  stopped) state=stopped ;;
@@ -4216,7 +4654,7 @@ forge_report() {
4216
4654
  forge_pr_var "$br" || FORGE_PR=""
4217
4655
  fi
4218
4656
  if [ -z "$pr" ] && [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
4219
- 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"
4220
4658
  FORGE_PR=""
4221
4659
  fi
4222
4660
  if [ -n "$FORGE_PR" ]; then
@@ -4233,6 +4671,7 @@ forge_report() {
4233
4671
  if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
4234
4672
  reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
4235
4673
  resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
4674
+ [ "$event" != failed ] || engine=$(hr_registry_get "$registry_file" "$br" engine)
4236
4675
  fi
4237
4676
 
4238
4677
  case "$event" in
@@ -4248,10 +4687,13 @@ forge_report() {
4248
4687
  resumed)
4249
4688
  text="The harness run on \`$br\` resumed." ;;
4250
4689
  failed)
4251
- if [ "$kind" = pr ]; then
4252
- 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}."
4253
4696
  else
4254
- trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
4255
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."
4256
4698
  fi ;;
4257
4699
  stopped)
@@ -4262,9 +4704,28 @@ forge_report() {
4262
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."
4263
4705
  else
4264
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."
4265
4708
  fi ;;
4266
4709
  round)
4267
- 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 ;;
4268
4729
  esac
4269
4730
 
4270
4731
  OPEN_QUESTIONS=""
@@ -4316,8 +4777,122 @@ forge_report() {
4316
4777
  return 0
4317
4778
  }
4318
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
+
4319
4890
  verb_report() {
4320
- 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
4321
4896
  exit "$EXIT_OK"
4322
4897
  }
4323
4898
 
@@ -4352,7 +4927,7 @@ deliver_pr_body() {
4352
4927
  verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
4353
4928
  done
4354
4929
  {
4355
- 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"
4356
4931
  [ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
4357
4932
  printf '\nA review that requests changes starts a user-review round on this branch.\n'
4358
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"
@@ -4374,11 +4949,280 @@ deliver_create() {
4374
4949
  fi
4375
4950
  }
4376
4951
 
4377
- # verb_deliver — after a `completed` bundle: find or open the branch's pull
4378
- # 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.
4379
5223
  verb_deliver() {
4380
- local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
4381
- 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=""
4382
5226
  if ! forge_on; then
4383
5227
  echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4384
5228
  exit "$EXIT_OK"
@@ -4389,99 +5233,159 @@ verb_deliver() {
4389
5233
  echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
4390
5234
  exit "$EXIT_OK"
4391
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
4392
5242
  forge_repo_var || exit "$EXIT_OK"
4393
5243
 
4394
5244
  forge_fetch_branch "$branch"
4395
5245
  forge_issue_var "$branch" || FORGE_ISSUE=""
4396
- forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4397
-
4398
- tmp="${RUNNER_TEMP-}"
4399
- if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4400
- tmp=$(mktemp -d) || tmp=""
4401
- made_tmp="$tmp"
4402
- 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)"; }
4403
5247
 
4404
5248
  if [ -n "$FORGE_PR" ]; then
4405
5249
  pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4406
5250
  echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
4407
5251
  elif [ "$lookup_failed" -eq 0 ]; then
4408
- base=$(hr_default_branch "$root" 2>/dev/null) || base=""
4409
- deliver_title_var "$branch"
4410
- if [ -z "$base" ]; then
4411
- create_err="the configured defaultBranch could not be read"
4412
- elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
4413
- create_err="its body file could not be written"
4414
- elif deliver_create "$base" "$branch" "$body" --draft; then
4415
- 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"
4416
5256
  else
4417
- create_err="$GH_ERR"
4418
- case "$GH_ERR" in
4419
- *"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
4420
- *)
4421
- # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4422
- echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4423
- if deliver_create "$base" "$branch" "$body"; then
4424
- opened=1
4425
- else
4426
- create_err="$GH_ERR"
4427
- fi ;;
4428
- esac
5257
+ create_err="$OPEN_ERR"
5258
+ forbidden="$OPEN_FORBIDDEN"
4429
5259
  fi
4430
- [ -z "$body" ] || rm -f "$body"
4431
- if [ "$opened" -eq 1 ]; then
4432
- FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
4433
- if [ -n "$FORGE_PR" ]; then
4434
- pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4435
- 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"
4436
5269
  else
4437
- opened=0
4438
- 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"
4439
5272
  fi
5273
+ else
5274
+ ready=not-draft
4440
5275
  fi
4441
5276
  fi
4442
5277
 
4443
- compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4444
- if [ -z "$pr_url" ]; then
4445
- 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"
4446
5318
  if [ "$forbidden" -eq 1 ]; then
4447
- 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"
4448
5320
  else
4449
- 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"
4450
5322
  fi
4451
- elif [ "$opened" -eq 0 ]; then
4452
- target="$FORGE_PR"
4453
- text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
4454
- elif [ -n "$FORGE_ISSUE" ]; then
4455
- target="$FORGE_ISSUE"
4456
- text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
4457
- Review it there; a review that requests changes starts another round."
5323
+ deliver_comment "$FORGE_ISSUE" "$tmp" "$text"
5324
+ posted="#$FORGE_ISSUE"
4458
5325
  else
4459
- target="$FORGE_PR"
4460
- 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"
4461
5327
  fi
4462
- if hr_phase_enabled "$root" qa; then
4463
- text="$text
5328
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4464
5329
 
4465
- The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
4466
- 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
+ }
4467
5335
 
4468
- if [ -z "$target" ]; then
4469
- echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4470
- 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
4471
5371
  {
4472
- 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"
4473
5380
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4474
5381
  } >"$file"
4475
- forge_comment "$target" completed "$branch" "$file" || :
5382
+ forge_comment "$FORGE_ISSUE" opened "$branch" "$file" || :
4476
5383
  rm -f "$file"
4477
5384
  else
4478
- 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
4479
5386
  fi
4480
5387
  [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4481
-
4482
- [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
4483
- [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
4484
- 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"
4485
5389
  exit "$EXIT_OK"
4486
5390
  }
4487
5391
 
@@ -4508,8 +5412,13 @@ control_cleanup() {
4508
5412
  return 0
4509
5413
  }
4510
5414
 
4511
- # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
4512
- # 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.
4513
5422
  control_post() {
4514
5423
  local text="$1" file status=0
4515
5424
  if ! forge_repo_var; then
@@ -4524,7 +5433,7 @@ control_post() {
4524
5433
  printf '%s\n' "$text"
4525
5434
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4526
5435
  } >"$file"
4527
- if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
5436
+ if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file" "" "$CONTROL_REPLY_ENGINE"; then
4528
5437
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4529
5438
  status=1
4530
5439
  fi
@@ -4661,11 +5570,47 @@ control_stopped_refuse() {
4661
5570
  esac
4662
5571
  }
4663
5572
 
4664
- # control_check_branch <branch> — the refusals both paths share, in order: a
4665
- # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4666
- # 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.
4667
5612
  control_check_branch() {
4668
- local b="$1" protected=0
5613
+ local b="$1" protected=0 exists=0 inflight=0
4669
5614
  if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
4670
5615
  control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
4671
5616
  fi
@@ -4679,8 +5624,25 @@ control_check_branch() {
4679
5624
  esac
4680
5625
  forge_fetch_branch "$b"
4681
5626
  if ! forge_recognised "$b"; then
4682
- control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
4683
- "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
4684
5646
  fi
4685
5647
  CONTROL_BRANCH="$b"
4686
5648
  }
@@ -4756,7 +5718,8 @@ control_issue_branch_var() {
4756
5718
  }
4757
5719
 
4758
5720
  # control_branch_from_issue <number> — control_issue_branch_var, its failures
4759
- # 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.
4760
5723
  control_branch_from_issue() {
4761
5724
  if ! control_issue_branch_var "$1"; then
4762
5725
  if [ -n "$ISSUE_BRANCH_ERR" ]; then
@@ -4765,7 +5728,7 @@ control_branch_from_issue() {
4765
5728
  control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4766
5729
  "Comment on the pull request of the run's branch instead."
4767
5730
  fi
4768
- control_check_branch "$ISSUE_BRANCH"
5731
+ control_check_branch "$ISSUE_BRANCH" started
4769
5732
  }
4770
5733
 
4771
5734
  # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
@@ -4800,6 +5763,7 @@ control_resume_dispatch() {
4800
5763
  *) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
4801
5764
  esac
4802
5765
  status="$EXIT_OK"
5766
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
4803
5767
  control_post "$done_text" || status="$EXIT_GH"
4804
5768
  # The job posts its own `resumed` comment; the labels say `running` now.
4805
5769
  forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
@@ -5102,6 +6066,7 @@ control_answer() {
5102
6066
  control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
5103
6067
  "Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
5104
6068
  esac
6069
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
5105
6070
 
5106
6071
  cmds=""
5107
6072
  for v in $CS_OPEN; do
@@ -5177,18 +6142,27 @@ RC_ERR=""
5177
6142
  # origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
5178
6143
  # recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
5179
6144
  # highest-numbered marked round's `collected_at`, empty when none is marked),
5180
- # RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
5181
- # 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
5182
- # 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.
5183
6151
  RC_SEEN_R=","
5184
6152
  RC_SEEN_C=","
5185
6153
  RC_MARKED_AT=""
6154
+ RC_MARKED_N=0
6155
+ RC_MARKED_C=""
6156
+ RC_NEWEST_N=0
5186
6157
  RC_STATE_REL=""
5187
6158
  round_markers_read() {
5188
- local event_id="${1-}" names name path n line marked_max=0
6159
+ local event_id="${1-}" names name path n line
5189
6160
  RC_SEEN_R=","
5190
6161
  RC_SEEN_C=","
5191
6162
  RC_MARKED_AT=""
6163
+ RC_MARKED_N=0
6164
+ RC_MARKED_C=""
6165
+ RC_NEWEST_N=0
5192
6166
  RC_EVENT_ROUND=""
5193
6167
  RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
5194
6168
  RC_STATE_REL="${RC_STATE_REL%/}"
@@ -5206,6 +6180,7 @@ round_markers_read() {
5206
6180
  [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
5207
6181
  n="${BASH_REMATCH[3]:-1}"
5208
6182
  n=$((10#$n))
6183
+ [ "$n" -le "$RC_NEWEST_N" ] || RC_NEWEST_N="$n"
5209
6184
  if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
5210
6185
  RC_ERR="the previous round \`$path\` could not be read"
5211
6186
  return 4
@@ -5215,9 +6190,10 @@ round_markers_read() {
5215
6190
  [[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
5216
6191
  RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
5217
6192
  RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
5218
- if [ "$n" -gt "$marked_max" ]; then
5219
- marked_max="$n"
6193
+ if [ "$n" -gt "$RC_MARKED_N" ]; then
6194
+ RC_MARKED_N="$n"
5220
6195
  RC_MARKED_AT="${BASH_REMATCH[1]}"
6196
+ RC_MARKED_C="${BASH_REMATCH[3]}"
5221
6197
  fi
5222
6198
  case ",${BASH_REMATCH[2]}," in
5223
6199
  *",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
@@ -5391,12 +6367,16 @@ REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the
5391
6367
  # control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
5392
6368
  # counted as settled, run in a command substitution so that its exit on a
5393
6369
  # failed read reaches this process as a status, not as an exit with no reply:
5394
- # BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
5395
- # 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.
5396
6374
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6375
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5397
6376
  control_settled_var() {
5398
6377
  local dir errfile line status=0
5399
6378
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6379
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5400
6380
  if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
5401
6381
  BS_ERR="a state directory could not be created under '$control_tmp'"
5402
6382
  return 1
@@ -5405,14 +6385,15 @@ control_settled_var() {
5405
6385
  control_dirs="$control_dirs $dir $errfile"
5406
6386
  branch="$CONTROL_BRANCH"
5407
6387
  line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
5408
- && 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=$?
5409
6390
  cat "$errfile" >&2 2>/dev/null || :
5410
6391
  if [ "$status" -ne 0 ] || [ -z "$line" ]; then
5411
6392
  BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
5412
6393
  [ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
5413
6394
  return 1
5414
6395
  fi
5415
- 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"
5416
6397
  return 0
5417
6398
  }
5418
6399
 
@@ -5758,7 +6739,7 @@ control_close() {
5758
6739
  fi
5759
6740
 
5760
6741
  # No forge_recognised check: a merged or deleted branch may no longer carry
5761
- # its ledger, and a listed `harness run <b>` run is what proves a harness run.
6742
+ # its task prompt or ledger, and a listed `harness run <b>` run is what proves a harness run.
5762
6743
  if ! control_state_var "$b"; then
5763
6744
  echo "::error::remote-run.sh: control: the state of the run on \`$b\` could not be read ($CS_ERR)"
5764
6745
  exit "$EXIT_GH"
@@ -5910,7 +6891,7 @@ collect_notify() {
5910
6891
  }
5911
6892
 
5912
6893
  verb_collect() {
5913
- local status=0 dir file out
6894
+ local status=0 dir file out reason route way
5914
6895
  if ! forge_on; then
5915
6896
  echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
5916
6897
  exit "$EXIT_OK"
@@ -5935,17 +6916,7 @@ verb_collect() {
5935
6916
  exit "$EXIT_OK" ;;
5936
6917
  esac
5937
6918
 
5938
- if [ -n "$pr_arg" ]; then
5939
- FORGE_PR="$pr_arg"
5940
- elif ! forge_pr_var "$branch"; then
5941
- echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
5942
- exit "$EXIT_OK"
5943
- fi
5944
- if [ -z "$FORGE_PR" ]; then
5945
- echo "remote-run.sh: collect: no open pull request; nothing to collect"
5946
- exit "$EXIT_OK"
5947
- fi
5948
-
6919
+ # Read before the pull request is required: a first run has none.
5949
6920
  control_tmp="${RUNNER_TEMP-}"
5950
6921
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5951
6922
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
@@ -5958,6 +6929,37 @@ verb_collect() {
5958
6929
  echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
5959
6930
  exit "$EXIT_OK"
5960
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
5961
6963
  if [ "$BS_SETTLED" != 1 ]; then
5962
6964
  echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
5963
6965
  exit "$EXIT_OK"
@@ -5994,7 +6996,11 @@ verb_collect() {
5994
6996
  echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
5995
6997
  exit "$EXIT_OK"
5996
6998
  fi
5997
- 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
5998
7004
  echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
5999
7005
  exit "$EXIT_OK"
6000
7006
  }
@@ -6078,6 +7084,7 @@ case "$verb" in
6078
7084
  list) verb_list ;;
6079
7085
  discard) verb_discard ;;
6080
7086
  report) verb_report ;;
7087
+ open) verb_open ;;
6081
7088
  deliver) verb_deliver ;;
6082
7089
  collect) verb_collect ;;
6083
7090
  control) verb_control ;;