autonomous-sdlc-harness 0.4.2 → 0.6.0

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.
Files changed (41) hide show
  1. package/dist/commands/init.js +113 -16
  2. package/dist/commands/init.js.map +1 -1
  3. package/dist/config/check.js +28 -6
  4. package/dist/config/check.js.map +1 -1
  5. package/dist/config/model.js +64 -5
  6. package/dist/config/model.js.map +1 -1
  7. package/dist/core/pluginIdentity.js +2 -0
  8. package/dist/core/pluginIdentity.js.map +1 -1
  9. package/dist/core/writer.js +10 -5
  10. package/dist/core/writer.js.map +1 -1
  11. package/dist/core/yamlScalar.js +14 -0
  12. package/dist/core/yamlScalar.js.map +1 -0
  13. package/dist/doctor/checks.js +454 -23
  14. package/dist/doctor/checks.js.map +1 -1
  15. package/dist/generators/githubWorkflows.js +125 -20
  16. package/dist/generators/githubWorkflows.js.map +1 -1
  17. package/dist/generators/repoRoot.js +17 -8
  18. package/dist/generators/repoRoot.js.map +1 -1
  19. package/dist/remote/githubActions.js +110 -7
  20. package/dist/remote/githubActions.js.map +1 -1
  21. package/dist/retrieval/pythonBackend.js +114 -0
  22. package/dist/retrieval/pythonBackend.js.map +1 -0
  23. package/dist/retrieval/setup.js +8 -0
  24. package/dist/retrieval/setup.js.map +1 -1
  25. package/package.json +1 -1
  26. package/templates/README.md +1 -1
  27. package/templates/github/workflows/harness-control.yml +184 -0
  28. package/templates/github/workflows/harness-resume.yml +9 -0
  29. package/templates/github/workflows/harness-run.yml +127 -12
  30. package/templates/github/workflows/harness-trigger.yml +144 -0
  31. package/templates/repo/gitignore +5 -0
  32. package/templates/scripts/README.md +1 -1
  33. package/templates/scripts/autonomous-watcher.sh +121 -249
  34. package/templates/scripts/create-worktree.sh +52 -10
  35. package/templates/scripts/docs-search-server.sh +88 -17
  36. package/templates/scripts/lib/harness-run-lib.sh +614 -14
  37. package/templates/scripts/remote-run.sh +3684 -144
  38. package/templates/scripts/scratch-run.sh +54 -73
  39. package/templates/state-dir/README-root.md +1 -1
  40. package/templates/state-dir/scratch/README.md +4 -2
  41. package/templates/state-dir/user_reviews/README.md +2 -2
@@ -4,52 +4,513 @@
4
4
  # workflow's input contract has exactly one producer on the shell side.
5
5
  #
6
6
  # THE VERBS AND THE EXIT MAP, stated once for every consumer (the watcher's
7
- # inbox and relay passes, the job-side `continue` / `poll`, the guard's deny
8
- # entry and the plugin commands that name this file):
7
+ # inbox pass, the job-side `continue` / `poll`, the guard's deny entry and the
8
+ # plugin commands that name this file):
9
9
  #
10
10
  # remote-run.sh dispatch <branch> --engine <kind> [--resume none|answer|pause]
11
11
  # [--answers-from <clar_dir> --indexes "<n> <n>..."]
12
12
  # [--park-loop-clear] [--chain <n>] [--repo <root>]
13
13
  # remote-run.sh pause <branch> [--repo <root>]
14
14
  # remote-run.sh warm [--repo <root>]
15
- # remote-run.sh stop <branch> [--repo <root>]
15
+ # remote-run.sh stop <branch> [--actor <login>] [--repo <root>]
16
16
  # remote-run.sh status <branch> [--repo <root>]
17
17
  # remote-run.sh sync <branch> [--repo <root>]
18
+ # remote-run.sh fetch <branch> <out_dir> [--repo <root>]
18
19
  # remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]
19
20
  # remote-run.sh save <branch> <out_dir> [--repo <root>]
20
21
  # remote-run.sh continue <branch> <bundle_dir> [--repo <root>]
21
22
  # remote-run.sh poll [--repo <root>]
22
23
  # remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]
23
24
  # remote-run.sh run-created-at <run_id> [--repo <root>]
25
+ # remote-run.sh start <branch> --prompt-file <file> [--repo <root>]
26
+ # remote-run.sh review <branch> --review-file <file> [--allow-no-run]
27
+ # [--actor <login>] [--reviewers <login,login,...>]
28
+ # [--source <https-url>] [--repo <root>]
29
+ # remote-run.sh trigger [--repo <root>] (its own exit map: its paragraph)
30
+ # remote-run.sh list [--repo <root>]
31
+ # remote-run.sh discard <dir> [--repo <root>]
32
+ # remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]
33
+ # (always 0, 1 only on a usage error: its paragraph)
34
+ # remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]
35
+ # (always 0, 1 only on a usage error: its paragraph)
36
+ # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
37
+ # (always 0, 1 only on a usage error: its paragraph)
38
+ # remote-run.sh control [--repo <root>] (its own exit map: its paragraph)
24
39
  # 0 sent (for stop: the action=stop marker was dispatched, and every
25
40
  # queued, waiting or in-progress `harness run` run of that branch was
26
- # asked to cancel, or there was none); for status: printed; for sync: the record is
41
+ # asked to cancel, or there was none); for status and fetch: printed
42
+ # (for fetch, `state: none` included); for sync: the record is
27
43
  # current (including "no run listed yet", which writes nothing); for
28
- # restore: restored, or no previous bundle (or an expired one, with a
29
- # `::warning::` line) under --resume none|pause;
44
+ # restore: restored, or no previous bundle of the branch's current
45
+ # lineage (or an expired one, with a `::warning::` line) under
46
+ # --resume none|pause;
30
47
  # for save: ALWAYS, whatever happened; for continue: whatever it
31
48
  # decided — every outcome a person must act on is a notification; for
32
49
  # poll: the tick finished; for pause-requested: such a run exists; for
33
- # run-created-at: printed
50
+ # run-created-at: printed; for review: placed, pushed and dispatched;
51
+ # for list: printed; for discard: <dir> removed, or it did not exist
34
52
  # 1 usage error, or the library or the configuration could not be
35
- # resolved; for sync and restore, a local copy or write failed; for
53
+ # resolved; for fetch, <out_dir> is not an existing, empty directory;
54
+ # for discard, <dir>'s parent does not resolve or the removal failed;
55
+ # for sync and restore, a local copy or write failed; for
36
56
  # pause-requested, also NO such run — a caller that reads 1 as "no
37
57
  # pause" passes arguments it has already validated
38
58
  # 2 refused, nothing sent or written: execution.target is not
39
- # github-actions (sending verbs); the branch's local record does not
40
- # carry `execution: github-actions` (status, sync); the record's mirror
41
- # working copy is missing, or a downloaded bundle is unrecognised
42
- # (sync, restore); the inputs payload is over the limit; a named answer
43
- # file is missing. For restore under --resume answer, "nothing more":
44
- # no previous bundle, the previous bundle expired (the message names
59
+ # github-actions (sending verbs, fetch, review and list); for
60
+ # review, a protected branch, a review file that is not a readable
61
+ # regular file, or a branch its settledness test reads as not settled
62
+ # (its paragraph); for sync, the
63
+ # branch's local record does not carry `execution: github-actions`; for
64
+ # status, a local record that does not carry `execution:
65
+ # github-actions`, or no record and `execution.target` not
66
+ # `github-actions`; the record's mirror working copy is missing, or a
67
+ # downloaded bundle is unrecognised (sync, restore, and status with no
68
+ # local record); the inputs payload is over the limit; a named answer
69
+ # file is missing (a relative --answers-from resolves against the
70
+ # caller's directory). For restore under --resume answer, "nothing more":
71
+ # no previous bundle of the branch's current lineage, the previous
72
+ # bundle expired (the message names
45
73
  # its expiry and the resume command), `HARNESS_INPUT_ANSWERS` not an object of
46
74
  # positive-integer keys to strings, or an answer whose `question_<n>.md`
47
75
  # is not at the top level of the previous bundle — nothing is restored
48
- # and no answer is written
76
+ # and no answer is written. For discard, <dir> does not resolve
77
+ # strictly inside `<state_dir>/scratch/`, is a symlink, or exists and
78
+ # is not a directory; nothing removed
49
79
  # 3 gh failed: not found, or a non-zero exit — the first line of gh's
50
80
  # stderr is named. For poll: the listing or the disable failed. For
51
81
  # pause-requested and run-created-at, also an answer that is not the
52
- # expected JSON; a caller never pauses on a failed read
82
+ # expected JSON; a caller never pauses on a failed read. For start, the
83
+ # dispatch failed AFTER the branch and its task prompt were pushed; for
84
+ # review, the listing failed, or the dispatch failed AFTER the review
85
+ # was pushed. For
86
+ # list, the listing or `git ls-remote` failed; nothing written
87
+ # 4 start: placement failed — the branch cut, the copy, the commit or the
88
+ # push — and nothing was dispatched; the working copy and the local
89
+ # branch the cut created were removed. review: placement failed — the
90
+ # copy (the branch checked out in another working copy included), the
91
+ # fast-forward, the commit or the push — and nothing was dispatched; a
92
+ # copy it cut was removed
93
+ #
94
+ # `start` IS THE ADAPTERS' ONE ENTRY: every trigger (an issue event, a forge
95
+ # dispatch, anything later) reduces to a branch and a task text and ends here.
96
+ # In order, stopping at the first failure: refuse a protected branch (2); refuse
97
+ # a prompt file that is not a readable regular file (2); cut the branch from
98
+ # `origin/<defaultBranch>` with `create-worktree.sh --no-bootstrap`; place the
99
+ # file at `<state_dir>/task_prompts/<branch>_task_prompt.md` in that working
100
+ # copy (the state directory resolved there, never in the main checkout), commit
101
+ # it as `chore: add task prompt for <branch>` and confirm `origin/<branch>`
102
+ # equals `HEAD` (each failure 4); then remove the working copy and the local
103
+ # branch it created — on every exit after the cut, success included and
104
+ # wherever `start` runs, because nothing reads the copy once the push has landed
105
+ # and a leftover branch makes the next cut of that name refuse; a copy or branch
106
+ # that existed before the cut is never removed — and `dispatch --engine task
107
+ # --resume none --chain 0`, composed by `verb_dispatch` itself. The placement is the library's
108
+ # (`hr_task_prompt_rel`, `hr_place_artifact`, `hr_commit_placed`,
109
+ # `hr_push_landed`), the same calls the watcher's inbox pass makes, so nothing
110
+ # downstream can tell where a task came from. It writes no registry record: a
111
+ # trigger job has no registry, and such a run needs no local record: the local
112
+ # commands act on it through GitHub.
113
+ #
114
+ # `fetch` IS THE COMMANDS' READ OF ONE BRANCH ON GITHUB, needing no local
115
+ # record. Gated like a sending verb. <out_dir> must be an existing, empty
116
+ # directory. It reads the newest `harness run <branch>` run through the same
117
+ # derivation `sync` makes (`remote_state`), downloads that run's state bundle
118
+ # into <out_dir> when one applies, and prints these lines, each always present
119
+ # and empty when unknown — the key names are a wire the commands parse:
120
+ # run_id: run_url: run_status: the newest `harness run <branch>` run
121
+ # state: `none` when no such run is listed, else the derivation's
122
+ # pause_reason: engine: detail: from the derivation
123
+ # open_questions: space-separated <n> of every top-level
124
+ # `<out_dir>/clarifications/<branch>/question_<n>.md` with
125
+ # no `answer_<n>.md` beside it, ascending
126
+ # bundle_dir: <out_dir> when a bundle was downloaded
127
+ #
128
+ # `discard` REMOVES THE DIRECTORY A COMMAND FETCHED INTO, so the command needs
129
+ # no recursive `rm` of its own. It removes <dir> only when the library's
130
+ # `hr_scratch_path_var` accepts it: strictly inside the checkout's
131
+ # `<state_dir>/scratch/`, not a symlink, and a directory when it exists
132
+ # (2 otherwise). A relative <dir>
133
+ # resolves against the caller's directory; the root is `--repo`, or else
134
+ # `hr_repo_root` of the working directory, as for `restore`. No `gh` call and
135
+ # no `execution.target` gate. A <dir> that does not exist is exit 0. It
136
+ # creates nothing and writes nothing else.
137
+ #
138
+ # `review` PLACES A USER REVIEW ROUND ON THE BRANCH TIP AND DISPATCHES IT, for
139
+ # `/autonomous-sdlc-harness:branch-user-review` on a run that executes on
140
+ # GitHub. In order, stopping at the first failure: refuse a protected branch
141
+ # and a review file that is not a readable regular file (2; a relative
142
+ # --review-file resolves against the caller's directory); refuse a branch that
143
+ # `branch_settled_var`, the settledness test `control` and `collect` share, reads as not
144
+ # settled (2). Its newest `harness run <branch>` run decides: none listed is
145
+ # settled only under --allow-no-run; a `completed` one is read by
146
+ # `remote_state`; any other is in flight as `running` until its `RUN_JOB_NAME`
147
+ # job has completed, and then read by `remote_state` as a finished run. Settled
148
+ # is `completed` or `failed`; `running`, `parked`, `park_loop` and `paused` are
149
+ # refused, an expired bundle naming its expiry. The refusal stays for this
150
+ # verb because a local round's file exists only on the caller's machine, so no
151
+ # later collection could pick it up. The bundle it reads is
152
+ # downloaded to `sync`'s directory, the one write a refusal makes. The copy: the main
153
+ # checkout's remote record's mirror when its `worktree` exists and is on the
154
+ # branch, never removed; otherwise `create-worktree.sh --existing
155
+ # --no-bootstrap` into `hr_worktree_dir`, removed with the local branch it
156
+ # DWIM-created on every exit, as `start` removes its cut. Either copy is
157
+ # fast-forwarded to `origin/<branch>`. No bootstrap runs: the copy holds one
158
+ # placed file. The round comes from `git ls-tree` of the copy's `HEAD` under
159
+ # `<state_dir>/user_reviews/` — the engine's own round source: each basename
160
+ # matching `^(.+)_review(_[0-9]+)?\.md$` whose captured branch EQUALS <branch>,
161
+ # the unsuffixed file being round 1; next is `<branch>_review.md` when none
162
+ # matched, else `<branch>_review_<max+1>.md`. It is placed, committed as
163
+ # `hr_user_review_subject`'s `chore: add user review for <branch>` and pushed
164
+ # (each failure 4); the cut copy is removed; then `dispatch --engine
165
+ # user_review --resume none --chain 0`, after which it holds until a `harness run
166
+ # <branch>` run whose `headSha` is the pushed commit is listed — `run_by_sha_var`,
167
+ # `trigger`'s bounded lookup — so a review job serialized behind it never reads
168
+ # the branch as settled before GitHub lists the new run; a lookup that runs out
169
+ # is one `::warning::` line and still exit 0. A remote record, when one exists, is
170
+ # set `running` / `user_review` in one write after the dispatch. Then the round
171
+ # is reported as `report round`. Under --reviewers (comma-separated logins) the
172
+ # note is `Round <round> from pull request #<pr> by @<a>, @<b>`, <pr> read from
173
+ # --source's `/pull/<n>` or else the branch's open pull request, with ` (<source>)`
174
+ # after it under --source. Otherwise it is `Round <round>`, plus ` from <source>`
175
+ # under --source, and ` by @<actor>` under --actor (a login, as for `stop`),
176
+ # else ` from a local session`.
177
+ # --allow-no-run EXISTS FOR A LOCALLY EXECUTED BRANCH REVIEWED ON GITHUB: such
178
+ # a branch has no `harness run <branch>` run, and its round runs through
179
+ # `WORKFLOW_RUN_FILE` because a GitHub-started round always does. Its local
180
+ # record carries no `execution: github-actions`, so it is neither read as the
181
+ # copy nor written; the round runs remotely, and the branch's local working
182
+ # copy falls behind `origin/<branch>` until the maintainer fast-forwards it.
183
+ # The flag widens only the none-listed refusal.
184
+ #
185
+ # `trigger` IS THE GITHUB EVENT ADAPTER, the one step of the trigger
186
+ # workflow's job: event -> (branch, task text) -> `start`. It handles
187
+ # `GITHUB_EVENT_NAME` `issues` and `repository_dispatch`; any other name, or an
188
+ # event file it cannot read, exits 1. Like `restore` it acts on `hr_repo_root` of the working
189
+ # directory, and it takes no part in the sending-verb gate: it gates itself, so
190
+ # a refusal can still be commented. It reads, only from the environment:
191
+ # GITHUB_EVENT_NAME, GITHUB_EVENT_PATH the event; each field is read by `jq`
192
+ # into a variable and is only ever an argument or file
193
+ # bytes, never shell source
194
+ # GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
195
+ # the URLs its comments name
196
+ # HARNESS_REMOTE_STOP non-empty: every start is refused
197
+ # HARNESS_TRIGGER_LABEL the trigger label; when empty, `DEFAULT_TRIGGER_LABEL`
198
+ # or `LEGACY_TRIGGER_LABEL`, per the paragraph below
199
+ # HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
200
+ # HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
201
+ # A test seam
202
+ # RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
203
+ # directory when empty
204
+ # THE LEGACY LABEL. The workflow `init` now writes always passes a non-empty
205
+ # `HARNESS_TRIGGER_LABEL`; only the previous release's workflow, which
206
+ # `init --upgrade-workflows` never re-renders, passes it empty, and its `if:`
207
+ # already ran the job for `LEGACY_TRIGGER_LABEL`. So when it is empty either
208
+ # `DEFAULT_TRIGGER_LABEL` or `LEGACY_TRIGGER_LABEL` is accepted, and the comment
209
+ # and the label removal name the one applied; a new workflow starts on
210
+ # `LEGACY_TRIGGER_LABEL` only when the variable names it.
211
+ # An `action` other than `labeled`, or another label, is one line and exit 0
212
+ # with no `gh` call. Otherwise refused, in this order, each refusal one issue
213
+ # comment naming the reason and the way on:
214
+ # 1. `HARNESS_REMOTE_STOP` is set
215
+ # 2. `hr_forge` is not `github` or `hr_execution_target` is not
216
+ # `github-actions` — before any authorisation, so a disabled trigger asks
217
+ # GitHub nothing about the labeller
218
+ # 3. the issue is not `open`
219
+ # 4. `sender.login` is `ghost` (GitHub's placeholder for a deleted account),
220
+ # empty, or not a login shape (`^[A-Za-z0-9][A-Za-z0-9-]*$`, plus `[bot]`
221
+ # for a `Bot`)
222
+ # 5. `sender.type` is not `User` and the login is not an exact entry of
223
+ # `HARNESS_TRIGGER_ALLOWED_BOTS` — checked by the listing alone, with no
224
+ # permission call, because the permission API answers `none` or 404 for a bot
225
+ # 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
226
+ # `write` — `maintain` reads as `write` and `triage` as `read` there; a
227
+ # failed call is "could not confirm write access", never a pass
228
+ # Refusals 4 to 6 are `authorise_actor`, the one actor check `control` reuses.
229
+ # Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
230
+ # branch with `hr_derive_branch <title> issue_<number>`, passing `gh` so a name
231
+ # with run-workflow history counts as taken (2 or 3 refused), writes
232
+ # the snapshot — `# <title>`, the body's bytes, `---` and a provenance sentence
233
+ # naming the issue, the labeller, the label and the time — and runs `start` as a
234
+ # child. After a start it looks up the `harness run <branch>` run whose
235
+ # `headSha` is the `origin/<branch>` commit `start` pushed, at most
236
+ # `TRIGGER_RUN_LOOKUP_TRIES` times, falling back to the branch's filtered run
237
+ # list, and comments the branch and that URL; the comment never names an older
238
+ # run of the branch. Every comment ends with the marker
239
+ # `<!-- sdlc-harness event=started branch=<branch> -->` on a start and
240
+ # `event=refused` otherwise (`branch=` empty before one is derived), and is
241
+ # followed by removing the label, so re-applying it is deliberate; then a start
242
+ # sets the state label `sdlc-harness: running`. That comment, that removal and
243
+ # that one label are the trigger's only writes to the issue; a refusal sets no
244
+ # label. A removal or a label set that fails is one `::warning::` line.
245
+ # A `repository_dispatch` reads `.action` (where GitHub puts the `event_type`)
246
+ # and `client_payload`'s `title`, `body` and `source`, the contract being
247
+ # {"event_type": TRIGGER_DISPATCH_EVENT_TYPE, "client_payload": {"title": …,
248
+ # "body": …, "source": …}}
249
+ # within GitHub's `client_payload` limits: at most 10 top-level properties and
250
+ # under 64 KB, so a longer task text does not fit. Another `.action` is one line
251
+ # and exit 0 with no `gh` call; an empty or missing `title` is refused. It has
252
+ # no labeller: GitHub sends one only for a fine-grained token with Contents
253
+ # write or a classic token with `repo`, so the token holder is the authority,
254
+ # and refusals 3 to 6 do not apply. The fallback name is `task_<GITHUB_RUN_ID>`
255
+ # and the snapshot's provenance sentence names the event type, `source` when
256
+ # set, and the time. There is no issue, so no comment and no label: every
257
+ # outcome is printed to stdout and appended as a Markdown block to
258
+ # `GITHUB_STEP_SUMMARY` when that is set, an append failure one `::warning::`.
259
+ # 0 started (commented), or ignored
260
+ # 1 not an `issues` or `repository_dispatch` event, or the event could not
261
+ # be read
262
+ # 2 refused (commented)
263
+ # 3 a `gh` step after the decision failed: the comment could not be posted
264
+ # (an `::error::` line; issues only), or `start` pushed the branch but
265
+ # its dispatch failed (commented with the manual Run-workflow way on)
266
+ # 4 `start` refused or failed its placement (commented)
267
+ #
268
+ # `report` TURNS A LIFECYCLE EVENT INTO ONE COMMENT AND ONE STATE LABEL, through
269
+ # the forge surface (its section states the functions). Job-side for its root,
270
+ # as `trigger` is, and outside the sending-verb gate: it does nothing, with one
271
+ # line, unless `hr_forge` is `github` and `hr_execution_target` is
272
+ # `github-actions`. The comment goes to the open same-repository pull request
273
+ # whose head is <branch> when origin's <branch> carries
274
+ # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
275
+ # the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
276
+ # task prompt, else nowhere. The label `STATE_LABEL_PREFIX<state>` replaces any
277
+ # other state label on that issue and that pull request, each when known; the
278
+ # label is a view, and the run list stays the authority. The state map:
279
+ # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
280
+ # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
281
+ # `failed` posts nothing when
282
+ # `remote_branch_stopped` finds the branch stopped, so a cancelled job never
283
+ # overwrites `stopped`. `completed` (deliver's) and `launched` (the trigger's
284
+ # own comment) are one line each, as is any other event. The comment names the
285
+ # next GitHub action — never a slash command — then <note> byte for byte, then
286
+ # this run's URL when `GITHUB_RUN_ID` is set, then the marker line
287
+ # `<!-- sdlc-harness event=<event> branch=<branch> -->`. The pause reason and
288
+ # reset come from the registry record, read only when the registry file exists.
289
+ # `parked` instead posts one comment per open question — `open_questions_in`
290
+ # over `$root`'s state directory, ascending — carrying `question_<n>.md` whole,
291
+ # cut at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
292
+ # the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, a
293
+ # comment whose first line is `COMMAND_HANDLE answer <n>` (`<n>` optional when
294
+ # one question is open) and whose following lines are the answer; the marker
295
+ # adds `question=<n>`. With no question open it posts the one notice. The label
296
+ # is set once per target, not per question. On a public repository a question
297
+ # comment and its answer are public, as the artifact already is
298
+ # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
299
+ # repository's Actions runs can see*).
300
+ # It never fails its caller: every problem is one line and exit 0.
301
+ #
302
+ # `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
303
+ # job's own `push-branch.sh`, which opens no pull request. Job-side for its
304
+ # root and self-gated, as `report` is (`forge_on`, one line when off). Anything
305
+ # but `status: completed` in <bundle_dir>/status.json, or no status.json, is
306
+ # one line and nothing sent. Otherwise, by the forge surface: an open
307
+ # same-repository pull request whose head is <branch> is reused and no second
308
+ # one is opened; else a draft is created against `defaultBranch`, titled from
309
+ # the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
310
+ # <branch>), whose body names the issue as `Started from #<n>.` — a plain
311
+ # mention, never a closing keyword — states what a review requesting changes
312
+ # and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
313
+ # marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
314
+ # set, so the adopter's CI runs without an approval click; every other call
315
+ # uses the job's token. A pull request so opened is authored by that token's
316
+ # owner, who therefore cannot request changes on it: use a machine account's
317
+ # token, another reviewer, or a local `branch-user-review` round. A create
318
+ # refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
319
+ # Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
320
+ # without `--draft`, and a second failure is named in the comment with the
321
+ # branch's compare URL. A pull-request lookup that fails opens nothing. Then
322
+ # one `completed` comment: on the issue naming the new pull request's URL; on
323
+ # the pull request when there is no issue, or when it existed before this run
324
+ # (saying the round finished); on the issue alone when none could be opened;
325
+ # nowhere when neither is known. It names reviewing and requesting changes as
326
+ # the next action, and with `phases.qa` true the local `branch-qa-test` still
327
+ # owed. Then `sdlc-harness: done` on the issue and the pull request, each when
328
+ # known. It writes at most one pull request, one comment and those labels, and
329
+ # never pushes. It never fails its caller: every problem is one line and exit 0.
330
+ #
331
+ # `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
332
+ # one step of the run workflow's `collect` job, which follows its `run` job, so
333
+ # a review `control` answered "collected" waits at most for the run in flight.
334
+ # Job-side for its root and self-gated, as `report` and `deliver` are
335
+ # (`forge_on`, one line when off). In order, each stop one line and exit 0:
336
+ # `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
337
+ # or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
338
+ # included); `branch_settled_var`, read as `control` reads it, finding the
339
+ # branch in flight — a newer run listed, or this run ending `parked`,
340
+ # `park_loop` or `paused` (a budget chain's included), whose own end collects
341
+ # next — or failing; `round_collect` finding no review requesting changes
342
+ # pending (inline comments alone start no round, as *Comment* starts none; they
343
+ # ride along in the next), or failing, a `::warning::` line. Otherwise `review
344
+ # <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
345
+ # <pull request url>` runs as a child, as `control` runs it, placing,
346
+ # dispatching and reporting the round. A child that exits non-zero gets exactly
347
+ # one comment on the pull request, with the `reply` marker, naming its last
348
+ # stderr line and saying the reviews stay there and that submitting a review
349
+ # requesting changes retries; there is no automatic retry. It never fails its
350
+ # caller: every outcome but a usage error is exit 0.
351
+ #
352
+ # `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
353
+ # step of the `WORKFLOW_CONTROL_FILE` job: one GitHub event -> one action on
354
+ # exactly one branch, carried out by this script's own verbs run as children,
355
+ # so it composes no dispatch itself. Job-side for its root, as `trigger` is,
356
+ # and outside the sending-verb gate: it gates itself after reading the event,
357
+ # so a refusal can still be replied to. The workflow's `if:` only saves a
358
+ # runner; every rule below holds without it. It handles `GITHUB_EVENT_NAME`
359
+ # `issue_comment` and `pull_request_review` (THE REVIEW, below); any other
360
+ # name, or an event file it cannot read, exits 1.
361
+ # It reads `.action`, `.comment.body`, `.issue.number`,
362
+ # `.issue.pull_request.url`, `.sender.login` and `.sender.type`, each by `jq`
363
+ # into a variable (data, never shell source), plus `trigger`'s environment.
364
+ # Ignored, with one line and no `gh` call: an action other than `created`; a
365
+ # body carrying `COMMENT_MARKER` anywhere (the harness's own comment, whoever
366
+ # posted it); and a body whose first line — a trailing CR stripped, leading
367
+ # spaces and tabs skipped — does not open with a word equal to
368
+ # `COMMAND_HANDLE`, compared lowercase. So `pause`, `Let's @sdlc-harness
369
+ # pause` and `> @sdlc-harness pause` start nothing. The verb is the next word,
370
+ # lowercased, and `CONTROL_ARGS` the rest of that line. Then refused, in this
371
+ # order, each a reply and exit 2:
372
+ # 1. `HARNESS_REMOTE_STOP` is set
373
+ # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
374
+ # GitHub nothing about the commenter
375
+ # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run
376
+ # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
377
+ # yet: the reply lists every command and names `docs/github-run-control.md`
378
+ # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
379
+ # `pr view`: a fork's pull request is refused, because this event carries the
380
+ # repository's secrets, and nothing from its head is checked out or run; one
381
+ # not `OPEN` is refused. On an issue, the branch of the LAST genuine start
382
+ # comment, read by a paginated comment listing: its author is
383
+ # `github-actions[bot]`, its first line opens with the trigger's sentence
384
+ # `Started a harness run on the branch ` and a backticked <b>, and its last
385
+ # non-empty line is byte for byte what `forge_marker started <b>` prints. A
386
+ # marker quoted mid-body, in a comment not opening with that sentence, or by
387
+ # anyone else, is never trusted; none is a refusal. Both paths then pass
388
+ # `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
389
+ # for is refused, then it is fetched, and one whose origin tip carries no
390
+ # flow-progress ledger (`forge_recognised`) is not a harness branch.
391
+ # THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
392
+ # `running` sends `pause <branch>` as a child and replies that the run yields at
393
+ # its next clean checkpoint; any other state is a refusal naming it. `stop`:
394
+ # state `none` is a refusal; otherwise `stop <branch> --actor <login>` as a
395
+ # child, which posts its own `stopped` comment to the run's target; on 0 a reply
396
+ # is ALWAYS posted where the command was typed too, so a command on the issue of
397
+ # a branch with a pull request is answered there; on 3 the reply says the stop
398
+ # was partial and to comment `stop` again. `resume` accepts only `paused`, any
399
+ # `pause_reason` (`expired` and `killed` included); `park_loop` is refused
400
+ # pointing at `clear`, `parked` pointing at `answer <n>` with the open indexes,
401
+ # `running`, `completed`, `failed` and `none` each naming the state. `clear`
402
+ # accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
403
+ # any other state is a refusal naming it. Each sends the local relay's dispatch,
404
+ # `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
405
+ # `clear` with `park_loop_clear` set, and no other command sets it. An empty
406
+ # engine is refused, never guessed (the `engine` input defaults to `task`),
407
+ # naming the Run workflow form. On 0 a reply, then `running` on the run's issue
408
+ # and pull request; on 2 a refusal and exit 2. `answer`: the first line is
409
+ # `answer <n>` and the answer every line below it, a trailing CR stripped from
410
+ # each line and its bytes otherwise unchanged; text after <n> on the first line
411
+ # is the answer when nothing follows below, and with no positive-integer <n>
412
+ # all of that line's text is. <n> may be left out only when exactly one
413
+ # question is open — issue comments have no threads. Refused, each a reply and
414
+ # exit 2: an empty answer; `park_loop` (pointing at `clear`); `paused` /
415
+ # `expired`, quoting its detail and pointing at `resume`, never treated as no
416
+ # park; `running`, naming the run, so a second answer never queues behind a job
417
+ # that a newer pending run in the per-branch `concurrency` group could cancel;
418
+ # any state but `parked`; no open question; several open and no <n>; an <n> not
419
+ # open, listing the open set; an empty engine, naming the Run workflow form.
420
+ # Otherwise the answer is written by `printf` (data, never shell source) to
421
+ # `answer_<n>.md` in a fresh directory under `RUNNER_TEMP`, and sent as
422
+ # `dispatch <branch> --engine <engine> --resume answer --answers-from <dir>
423
+ # --indexes <n> --chain 0`: one answer, one dispatch with one entry. That is
424
+ # safe because an `answer` job whose park is not fully answered stops parked
425
+ # before any session (`autonomous-watcher.sh` -> `run_job`), its bundle then
426
+ # carrying the `answer_<n>.md` restore wrote, so the next answer's job finds the
427
+ # set complete. The payload limit is `dispatch`'s alone: its refusal is quoted,
428
+ # with shortening the answer or committing it to a file on the branch as the
429
+ # way on. On 0 with no other question open, a reply that the run resumes and
430
+ # `running` on its issue and pull request; with others open, a reply naming
431
+ # them, and the label stays `parked`. An answer becomes a comment on the item,
432
+ # public on a public repository, as the question already is.
433
+ # THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
434
+ # `.review.body`, `.review.id`, `.review.html_url`, `.review.submitted_at`,
435
+ # `.pull_request.number`, `.pull_request.head.ref`,
436
+ # `.pull_request.head.repo.full_name`, `.sender.login` and `.sender.type`.
437
+ # Ignored, with one line and no `gh` call: an action other than `submitted`; a
438
+ # state other than `REVIEW_ROUND_STATE`, compared lowercase (the REST API
439
+ # reports it uppercase); a body carrying `COMMENT_MARKER`; and a head
440
+ # repository other than `GITHUB_REPOSITORY`, not even replied to, because a
441
+ # fork's review job holds a read-only token. So *Comment* and *Approve* start
442
+ # nothing, and draft status plays no part. Then gates 1-3 above, in order, each
443
+ # a reply on the pull request, and `control_check_branch` on the head; then a
444
+ # head whose origin tip carries no `<state>/story_plans/<head>_story_plan.md`
445
+ # is refused, because the round reads its story index. A REVIEW IS NEVER
446
+ # REFUSED FOR A RUN IN FLIGHT. The head is read by `branch_settled_var`, no run
447
+ # counting as settled, in a command substitution so a failed read is a reply
448
+ # (exit 3) rather than an exit with none. Not settled: a reply and exit 0,
449
+ # nothing pushed or dispatched — `your review is part of round <n>, which is
450
+ # <state>` when a marker on origin's tip records the event's review id, else
451
+ # `your review was collected`, naming the state and saying the next round
452
+ # starts by itself when that run finishes, plus the state's way on: `answer
453
+ # <n>` for `parked`, `clear` for `park_loop`, `resume` for any `paused` but
454
+ # `usage`, and for `usage` that it resumes after the reset. The review stays on
455
+ # the pull request, and a later round collects it. Settled: a round is CUMULATIVE,
456
+ # built by `round_collect` in a fresh file under `RUNNER_TEMP` from ONE
457
+ # paginated `pulls/<n>/reviews` and ONE paginated `pulls/<n>/comments` listing
458
+ # — never the per-review endpoint, which carries no `line` — the event's own
459
+ # review merged when the listing lacks it. What earlier rounds consumed is the
460
+ # union of the review and comment ids their marker lines record, read from
461
+ # origin's tip; the time boundary is the highest-numbered marked round's
462
+ # `collected_at` less `ROUND_OVERLAP_SECS`, or, when no round is marked, the
463
+ # committer time of the branch's newest `user_reviews/<head>_review[_<n>].md`
464
+ # (none when there is no round). Pending: a submitted review (never
465
+ # `PENDING`) whose id is unrecorded, whose body carries no `COMMENT_MARKER`,
466
+ # whose `submitted_at` is at or after the boundary, and which either has state
467
+ # `REVIEW_ROUND_STATE` or a non-blank body; and an
468
+ # inline comment, by any author and whatever its review's state, whose id is
469
+ # unrecorded, whose body carries no `COMMENT_MARKER`, and whose `created_at` is
470
+ # at or after the boundary or whose review is pending. Every distinct author
471
+ # of a pending item passes `authorise_actor`; a refused author's items are
472
+ # dropped with one line naming the login, `AUTH_WHY` and the count, and a
473
+ # failed permission call fails the collection. A round is placed only when at
474
+ # least one kept review has state `REVIEW_ROUND_STATE`; a *Comment* or
475
+ # *Approve* review rides along in the next round one requesting changes starts.
476
+ # The file: one `## Review by @<login>` section per pending review, oldest
477
+ # `submitted_at` first, holding the body verbatim (or `(The review carries no
478
+ # summary.)`) and a provenance line naming its state — `Requested changes on
479
+ # pull request #<n> (<url>) at <submitted_at>.`, `Commented on pull request
480
+ # …`, `Approved pull request …`, `Reviewed pull request … at <submitted_at>;
481
+ # the review has since been dismissed.`, or for any other state `Reviewed
482
+ # pull request … at <submitted_at> (state <state>).`; then, when any is
483
+ # kept, `## Inline comments`, oldest `created_at` first; then the marker line
484
+ # `<!-- sdlc-harness round collected_at=<utc> reviews=<id,…> comments=<id,…> -->`
485
+ # listing exactly the ids written. Each comment is a `### `<path>`, line
486
+ # <n>` heading (`original line <n> (outdated)` when `line` is null), `Made on
487
+ # commit `<original_commit_id or commit_id>`.`, `By @<login>: <url>`, its body
488
+ # verbatim, and its `diff_hunk` in a `diff` fence one backtick longer than the
489
+ # hunk's longest backtick run, at least three — the commit and hunk let the fix plan re-locate
490
+ # a line the fixes moved. With no review pending, nothing is placed: a reply
491
+ # names the round whose marker records the event's review, else says nothing
492
+ # is pending, and exit 0. Then `review <head> --review-file <file>
493
+ # --allow-no-run --reviewers <logins> --source <pull request url>` runs as a
494
+ # child, which fast-forwards, commits `chore: add user review for <head>`, pushes,
495
+ # dispatches `engine: user_review`, waits for its run to be listed and reports
496
+ # the round itself. Its 0 is exit 0 with nothing more posted; 2 means the branch
497
+ # became unsettled between the two reads, answered with the in-flight reply
498
+ # above (the state read again) and exit 0; 3 a reply quoting the re-send line,
499
+ # exit 3; 4 a reply that placement failed and nothing was dispatched, exit 4.
500
+ # A refusal reads `@<login>: `review` was not run: …`, and no reply to a review
501
+ # asks for it to be submitted again to be kept: a reply that started nothing
502
+ # says the reviews stay on the pull request for the next round, and that
503
+ # submitting a review requesting changes retries now.
504
+ # A child's failure is a reply
505
+ # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
506
+ # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
507
+ # `@<login>: `<verb>` was not run: <reason>. <way on>`.
508
+ # 0 handled (replied), or ignored
509
+ # 1 neither an `issue_comment` nor a `pull_request_review` event, or the event could not be read
510
+ # 2 refused (replied)
511
+ # 3 a `gh` step failed: the reply could not be posted (an `::error::`
512
+ # line), or the action failed and was replied to
513
+ # 4 a review's round could not be placed; nothing was dispatched (replied)
53
514
  #
54
515
  # `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
55
516
  # its job, before and (under `always()`) after the harness step. Without
@@ -84,7 +545,21 @@
84
545
  # with the exact bytes, after checking every entry first; and with
85
546
  # `HARNESS_INPUT_PARK_LOOP_CLEAR` exactly `true` it sets `park_loop_cycles` to
86
547
  # "0" in the restored `autonomous_logs/remote_status.json`. No bundle in any
87
- # run is an ordinary first job (exit 0, one line) except under --resume answer.
548
+ # candidate run is an ordinary first job (exit 0, one line) except under
549
+ # --resume answer, whose refusal names the current lineage.
550
+ #
551
+ # THE CANDIDATES ARE BOUNDED TO THE BRANCH'S CURRENT LINEAGE, so a branch
552
+ # recreated under a reused name never restores an earlier, unrelated run's
553
+ # bundle. The listing reads each run's `headSha`, and `lineage_commits_var`
554
+ # lists `git rev-list refs/remotes/origin/<defaultBranch>..HEAD` in the job's
555
+ # checkout; a run whose `headSha` is not among those commits, or that carries
556
+ # none, is dropped before the walk, so the expired-bundle stop applies to
557
+ # lineage runs only. When any was dropped it prints `skipped <n> finished
558
+ # run(s) of <branch> from before its current lineage`. When the lineage cannot
559
+ # be listed — the configuration unreadable, `origin/<defaultBranch>` not
560
+ # present, or HEAD carrying no commit beyond it — every finished run is a
561
+ # candidate, as before the bound, and it prints `the lineage of <branch> is
562
+ # not bounded (<reason>); every finished run of it is a candidate`.
88
563
  #
89
564
  # `save` WRAPS `hr_remote_bundle_write` into <out_dir>, and with
90
565
  # `GITHUB_STEP_SUMMARY` set appends a Markdown table of the bundle's `status`,
@@ -116,7 +591,8 @@
116
591
  # so it names the repository rather than a runner path
117
592
  # GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
118
593
  # Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
119
- # `failed`. A re-dispatch is `dispatch <branch> --engine <status.json engine>
594
+ # `failed`, and each is then reported as `report` reports that event, with a
595
+ # note of its own that names no slash command and no shell command. A re-dispatch is `dispatch <branch> --engine <status.json engine>
120
596
  # --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
121
597
  #
122
598
  # `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
@@ -204,11 +680,28 @@
204
680
  # `gh run view <run_id> --json createdAt` as an epoch second.
205
681
  #
206
682
  # `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
207
- # it started with, so they test the record's `execution` field and never
208
- # `execution.target`; the configuration is still read for `stateDir`.
683
+ # it started with, so where a record exists they test its `execution` field and
684
+ # never `execution.target`; the configuration is still read for `stateDir`.
685
+ # Only `status` with no record reads the key.
686
+ #
687
+ # `status` WITH NO LOCAL RECORD (no registry file, or no record of the branch)
688
+ # is gated like a sending verb and answers from GitHub alone: the same runs
689
+ # listing, then `remote_state` with the bundle downloaded into a `mktemp -d`
690
+ # directory removed on exit, printing the state, pause reason, detail, engine
691
+ # and run URL, each open question (the rule `fetch` states) with its `## Q<k>`
692
+ # heading lines; an expired bundle's expired line is its `detail`. No `harness run
693
+ # <branch>` run listed prints one line and exits 0. There is no sync to compare
694
+ # against, so no finished-since line.
695
+ #
696
+ # `list` IS `branch-status`'s DIGEST: one listing (`list_all_runs`) and one
697
+ # `git ls-remote --heads origin`, never a bundle. It prints `on GitHub, no
698
+ # local record: <branch> <url>` for each branch `unrecorded_runs` keeps — its
699
+ # newest run titled exactly `harness run <branch>`, no registry record,
700
+ # unprotected and a live head on origin — or `no run on GitHub without a local
701
+ # record`.
209
702
  #
210
- # `status` WRITES NOTHING AT ALL — no registry (it does not even create an
211
- # absent one), no download, no file. It prints the branch's newest runs titled
703
+ # `status` WITH A RECORD WRITES NOTHING AT ALL — no registry (it does not even
704
+ # create an absent one), no download, no file. It prints the branch's newest runs titled
212
705
  # `harness run <branch>` or `harness pause <branch>` (bounded), the record's
213
706
  # `status`, `pause_reason`, `remote_run_url` and `remote_synced_at`, whether
214
707
  # a `harness run` finished after the last sync, and — reading the newest
@@ -234,7 +727,8 @@
234
727
  # `mirror` mode into the record's `worktree`, `run.log` copied to the main
235
728
  # checkout's `autonomous_logs/<branch>.remote.log`, and `status`,
236
729
  # `pause_reason`, `usage_resume_at`, `park_loop_cycles`, `remote_run_id`,
237
- # `remote_run_url`, `remote_detail` and `remote_synced_at` written. A
730
+ # `remote_run_url`, `remote_detail` and `remote_synced_at` written, and
731
+ # `engine` when the bundle names `task`, `user_review` or `docs`. A
238
732
  # `mirror` restore places no planning draft
239
733
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
240
734
  # older finished run carries one): a job that died before its upload.
@@ -270,7 +764,7 @@
270
764
  # is a wire: `pause` and `stop` send the branch as their `branch` input for
271
765
  # exactly that reason.
272
766
  #
273
- # `stop` DOES THREE THINGS, IN THIS ORDER. (1) It ALWAYS dispatches action=stop
767
+ # `stop` DOES FOUR THINGS, IN THIS ORDER. (1) It ALWAYS dispatches action=stop
274
768
  # on the branch — a jobless run titled `harness stop <branch>` that GitHub keeps
275
769
  # as the stop marker `continue` and `poll` read. It is first because it is the
276
770
  # only part that reaches a usage-paused run waiting on the resume poller, which
@@ -282,7 +776,12 @@
282
776
  # after a failure. (3) Only when (1) and (2) all succeeded, and only when a
283
777
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
284
778
  # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
285
- # and exits 3, so running `stop` again is the remedy.
779
+ # and exits 3, so running `stop` again is the remedy. (4) A complete stop is then
780
+ # reported as `report stopped` with the note `Stopped by @<actor>.` under
781
+ # `--actor` (a login, the trigger's shape plus an optional `[bot]`; anything else
782
+ # is a usage error), else one naming a local stop; a partial stop reports
783
+ # nothing. The cancelled job's own `failed` is then posted nowhere, because
784
+ # `report` finds the branch stopped.
286
785
  #
287
786
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
288
787
  # --json defaultBranchRef`), which may differ from the configured
@@ -294,7 +793,28 @@
294
793
  # root's `harness.config.json`.
295
794
  #
296
795
  # WHAT IT NEVER DOES. It never launches a local session, never writes the
297
- # inbox, never pushes, and never watches a run it sent. Its only writes are the
796
+ # inbox, and never watches a run it sent beyond the bounded lookup of its
797
+ # listing that `trigger` and `review` make. Only `start` and `review` push, and
798
+ # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
799
+ # the prompt committed on `origin/<branch>`, through a working copy and a local
800
+ # branch it removes before it returns; `review`'s are the round committed on
801
+ # `origin/<branch>`, through the record's mirror or a copy and a local branch
802
+ # it removes, the record's `status` / `engine`, the bundle download
803
+ # directory `sync` uses, and `report`'s writes for the round. A user's chain-0 `dispatch --resume answer|pause`
804
+ # writes the record's `status`, `resumed_at` and `resume_kind` in one write
805
+ # when the main checkout's registry file exists and holds a record with
806
+ # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
807
+ # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
808
+ # for a dispatch event a block in the step summary. `report` writes its comment
809
+ # file under `RUNNER_TEMP` (removed), one comment and the state labels on the
810
+ # issue and the pull request, and nothing local. `deliver` writes its body and
811
+ # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
812
+ # comment and the state labels. `control` writes its reply file and its
813
+ # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
814
+ # what the child verb it runs writes. `collect` writes its round file and its
815
+ # settledness directory under `RUNNER_TEMP` (removed), at most one comment on
816
+ # the pull request, plus what its `review` child writes. `stop`, `continue` and `poll` also make
817
+ # `report`'s writes for each event they report. Every other verb's only writes are the
298
818
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
299
819
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
300
820
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
@@ -304,7 +824,10 @@
304
824
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
305
825
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
306
826
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
307
- # and `current/`. `pause-requested` and `run-created-at` write nothing.
827
+ # and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
828
+ # <dir> only. `pause-requested`,
829
+ # `run-created-at`, `list` and `status` write nothing; a no-record `status`
830
+ # downloads into a temporary directory it removes on exit.
308
831
  #
309
832
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
310
833
  # rename there is an edit here, byte for byte:
@@ -313,6 +836,20 @@
313
836
  # STATE_ARTIFACT_NAME mirrors STATE_ARTIFACT_NAME
314
837
  # POLL_STATE_ARTIFACT_NAME mirrors POLL_STATE_ARTIFACT_NAME
315
838
  # HARNESS_GH_CLI mirrors GH_CLI_VARIABLE (the binary run as `gh`)
839
+ # HARNESS_TRIGGER_LABEL mirrors TRIGGER_LABEL_VARIABLE
840
+ # DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
841
+ # LEGACY_TRIGGER_LABEL mirrors LEGACY_TRIGGER_LABEL
842
+ # HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
843
+ # TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
844
+ # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
845
+ # COMMAND_HANDLE mirrors COMMAND_HANDLE
846
+ # COMMAND_VERBS mirrors COMMAND_VERBS, space-separated
847
+ # COMMENT_MARKER mirrors COMMENT_MARKER
848
+ # REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
849
+ # STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
850
+ # RUN_STATES mirrors RUN_STATES, space-separated, same order
851
+ # PR_CREATE_SETTING mirrors PR_CREATE_SETTING
852
+ # PR_CREATE_SETTING_PATH mirrors PR_CREATE_SETTING_PATH
316
853
  #
317
854
  # `set -u` WITHOUT `-e`: every refusal is reported with its own exit code rather
318
855
  # than aborting mid-decision.
@@ -345,6 +882,50 @@
345
882
  # bash scripts/remote-run.sh pause feat_x -> 3, names `boom`
346
883
  # gh absent HARNESS_GH_CLI=/nonexistent bash scripts/remote-run.sh warm -> 3
347
884
  #
885
+ # start needs the adopted tree on origin's default branch (commit and push
886
+ # it first) and a prompt file outside the checkout, say /tmp/p.md:
887
+ # start bash scripts/remote-run.sh start feat_x --prompt-file /tmp/p.md
888
+ # -> 0; origin/feat_x gains `chore: add task prompt for feat_x`,
889
+ # then "$s.log" gains the same `workflow run` line as dispatch
890
+ # protected bash scripts/remote-run.sh start main --prompt-file /tmp/p.md
891
+ # -> 2, log unchanged, nothing pushed
892
+ # no prompt bash scripts/remote-run.sh start feat_y --prompt-file /nonexistent
893
+ # -> 2, log unchanged, nothing pushed
894
+ # review after that start, a `run list` answer whose newest `harness run
895
+ # feat_x` run is `completed` with a bundle saying `completed`:
896
+ # bash scripts/remote-run.sh review feat_x --review-file /tmp/r.md
897
+ # -> 0; origin/feat_x gains `chore: add user review for feat_x`
898
+ # placing feat_x_review.md, then one `-f engine=user_review`
899
+ # dispatch; no copy or local feat_x is left; the stub lists no
900
+ # run of the pushed `headSha`, so one `::warning::` line follows
901
+ # (export HARNESS_TRIGGER_LOOKUP_SECS=0 to skip the waits)
902
+ # in flight the newest run `in_progress`, its jobs listing no completed
903
+ # `run` job -> 2, nothing pushed or sent
904
+ # no run no `harness run feat_x` run listed -> 2, nothing pushed; with
905
+ # --allow-no-run -> 0, placed and dispatched as `review` above
906
+ # reported report's setup, then review ... --actor alice --source
907
+ # https://github.com/o/r/pull/12#pullrequestreview-1 -> 0; one
908
+ # comment on 7 naming `Round <n>`, the source and `@alice`, then
909
+ # `sdlc-harness: running` on 7
910
+ # fetch t=$(mktemp -d); bash scripts/remote-run.sh fetch feat_x "$t"
911
+ # -> 0; prints `state: running` (or, with no run listed,
912
+ # `state: none`), every other key present
913
+ # discard mkdir -p sdlc-harness/scratch/branch-pause-feat_x, then
914
+ # bash scripts/remote-run.sh discard sdlc-harness/scratch/branch-pause-feat_x
915
+ # -> 0, the directory gone; discard sdlc-harness/autonomous_logs
916
+ # -> 2, nothing removed; no gh call either way
917
+ #
918
+ # no record with no registry, the "a bundle" stub below with question_1.md:
919
+ # bash scripts/remote-run.sh status feat_x -> 0; prints `state:
920
+ # parked` and `open question question_1.md`; nothing written
921
+ # list start's setup, a feat_x pushed to origin, no registry, and a
922
+ # `run list` answer carrying `headBranch` feat_x, `displayTitle`
923
+ # `harness run feat_x` and a `url`: bash scripts/remote-run.sh
924
+ # list -> 0; prints `on GitHub, no local record: feat_x <url>`;
925
+ # no registry is created
926
+ # adopt bash scripts/remote-run.sh adopt -> 1, an unknown verb; no gh
927
+ # call, no registry
928
+ #
348
929
  # status and sync need a remote record, and a `run list` answer whose runs
349
930
  # carry `displayTitle` `harness run feat_x` and a `url`:
350
931
  # r=sdlc-harness/autonomous_logs/registry.json; mkdir -p "${r%/*}"
@@ -380,6 +961,13 @@
380
961
  # first job a `run list` answer with no finished run: --resume none -> 0;
381
962
  # --resume answer -> 2
382
963
  # own run GITHUB_RUN_ID=<the bundle run's id> -> that run is skipped
964
+ # lineage the checkout on feat_x one commit beyond origin/main, and a `run
965
+ # list` answer holding only an older completed run whose `headSha`
966
+ # is another commit: --resume none -> 0, prints `skipped 1 finished
967
+ # run(s) of feat_x from before its current lineage` and `this is
968
+ # its first job`, no `run download`
969
+ # own lineage that same branch plus a newer completed run whose `headSha`
970
+ # is that commit -> that run's bundle is restored
383
971
  # expired the artifact list answering {"artifacts":[{"name":"harness-state",
384
972
  # "expired":true,"expires_at":"2026-01-02T00:00:00Z"}]}: --resume
385
973
  # pause -> 0, a `::warning::` line, no `run download`, no older
@@ -432,6 +1020,68 @@
432
1020
  # run-created-at a stub answering `run view 42 --json createdAt` with
433
1021
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
434
1022
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
1023
+ #
1024
+ # trigger needs start's setup plus `"forge": "github"`, an event file e.json
1025
+ # {"action":"labeled","label":{"name":"sdlc-harness"},"sender":{"login":"alice",
1026
+ # "type":"User"},"issue":{"number":7,"title":"Add comments","body":"x",
1027
+ # "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
1028
+ # answering `api repos/o/r/collaborators/alice/permission` with
1029
+ # {"permission":"write"}; export GITHUB_EVENT_NAME=issues GITHUB_EVENT_PATH=e.json
1030
+ # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0:
1031
+ # trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
1032
+ # the prompt commit, "$s.log" gains `workflow run harness-run.yml
1033
+ # --ref add_comments ...`, `issue comment 7 ...` naming the branch,
1034
+ # then `issue edit 7 ... --remove-label sdlc-harness`
1035
+ # read the permission answer {"permission":"read"} -> 2, no `workflow
1036
+ # run`, one comment naming write access, the label removed
1037
+ # ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
1038
+ # dispatch GITHUB_EVENT_NAME=repository_dispatch GITHUB_RUN_ID=9
1039
+ # GITHUB_STEP_SUMMARY=/tmp/s, e.json {"action":<TRIGGER_DISPATCH_EVENT_TYPE>,
1040
+ # "client_payload":{"title":"Add tags","body":"x","source":"jira"}}
1041
+ # -> 0; `workflow run ... --ref add_tags ...`, /tmp/s names
1042
+ # add_tags, no `issue` call; without "title" -> 2, no `workflow run`
1043
+ #
1044
+ # report needs trigger's setup, a branch feat_x pushed carrying its task
1045
+ # prompt (ending in a `Started from https://github.com/o/r/issues/7 by @alice`
1046
+ # line) and `flow_progress/feat_x_progress.md`, and a stub answering `pr
1047
+ # list` and `api repos/o/r/issues/7/labels` with []; GITHUB_REPOSITORY=o/r:
1048
+ # paused bash scripts/remote-run.sh report paused feat_x -> 0; "$s.log"
1049
+ # gains `api --method POST repos/o/r/issues/7/comments -F body=@…`
1050
+ # naming `@sdlc-harness resume`, then `api --method POST
1051
+ # repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
1052
+ # forge off `"forge": "none"`, then the same -> 0, one line, "$s.log" unchanged
1053
+ #
1054
+ # deliver needs report's setup, a stub answering the create with
1055
+ # https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
1056
+ # carries schema "1" and status completed:
1057
+ # deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
1058
+ # a `--draft` create with `--base main --head feat_x`, then one
1059
+ # comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
1060
+ # 7 and 12
1061
+ # not done status parked, or an empty <b> -> 0, one line, "$s.log" unchanged
1062
+ #
1063
+ # collect needs deliver's setup, a story index on feat_x, a stub answering
1064
+ # `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
1065
+ # `completed` `harness run feat_x` run whose bundle says `completed`, `api
1066
+ # --paginate repos/o/r/pulls/12/reviews` with one `CHANGES_REQUESTED` review
1067
+ # by alice, and the permission call with {"permission":"write"}:
1068
+ # collect bash scripts/remote-run.sh collect feat_x -> 0; origin/feat_x
1069
+ # gains `chore: add user review for feat_x`, then one `-f
1070
+ # engine=user_review` dispatch; with no review listed -> 0, one
1071
+ # line, nothing pushed or dispatched
1072
+ #
1073
+ # control needs report's setup, a stub answering `pr view 12 ...` with
1074
+ # {"headRefName":"feat_x","isCrossRepository":false,"state":"OPEN"}, the
1075
+ # permission call with {"permission":"write"}, and `run list` with an
1076
+ # `in_progress` `harness run feat_x` run; an event file c.json
1077
+ # {"action":"created","comment":{"body":"@sdlc-harness pause"},"issue":{"number":12,
1078
+ # "pull_request":{"url":"x"}},"sender":{"login":"alice","type":"User"}};
1079
+ # export GITHUB_EVENT_NAME=issue_comment GITHUB_EVENT_PATH=c.json GITHUB_REPOSITORY=o/r:
1080
+ # pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
1081
+ # run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1082
+ # then one comment on 12 naming @alice
1083
+ # ignored c.json's body `Let's @sdlc-harness pause` -> 0, one line,
1084
+ # "$s.log" unchanged
435
1085
 
436
1086
  set -u
437
1087
 
@@ -448,6 +1098,23 @@ WORKFLOW_RUN_FILE='harness-run.yml'
448
1098
  WORKFLOW_RESUME_FILE='harness-resume.yml'
449
1099
  STATE_ARTIFACT_NAME='harness-state'
450
1100
  POLL_STATE_ARTIFACT_NAME='harness-poll-state'
1101
+ DEFAULT_TRIGGER_LABEL='sdlc-harness'
1102
+ LEGACY_TRIGGER_LABEL='harness'
1103
+ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1104
+ WORKFLOW_CONTROL_FILE='harness-control.yml'
1105
+ COMMAND_HANDLE='@sdlc-harness'
1106
+ COMMAND_VERBS='answer pause resume stop clear'
1107
+ COMMENT_MARKER='<!-- sdlc-harness'
1108
+ REVIEW_ROUND_STATE='changes_requested'
1109
+ STATE_LABEL_PREFIX='sdlc-harness: '
1110
+ RUN_STATES='running parked paused done failed stopped'
1111
+ PR_CREATE_SETTING='Allow GitHub Actions to create and approve pull requests'
1112
+ PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
1113
+ # The most bytes of a question file one park comment carries. An issue comment
1114
+ # holds 262,144 bytes of UTF-8, and the refusal text's character count is not
1115
+ # to be trusted (docs/github-integration-research.md -> S6); the margin is the
1116
+ # framing lines and the marker.
1117
+ QUESTION_COMMENT_MAX_BYTES=250000
451
1118
  GH="${HARNESS_GH_CLI:-gh}"
452
1119
 
453
1120
  # How many runs `status` prints, and how many `run list` returns for status
@@ -468,6 +1135,17 @@ POLL_GIVE_UP_AFTER_MINUTES_DEFAULT=360
468
1135
  # How many of the poller's own runs `poll` searches for the previous tick's
469
1136
  # state artifact; each one without it costs an artifact lookup.
470
1137
  POLL_STATE_RUNS_LIMIT=10
1138
+ # `trigger`'s bound on looking up the run its `start` dispatched; the wait
1139
+ # between tries is `HARNESS_TRIGGER_LOOKUP_SECS`, a test seam.
1140
+ TRIGGER_RUN_LOOKUP_TRIES=6
1141
+ TRIGGER_LOOKUP_SECS_DEFAULT=5
1142
+ # The `run` job's name in WORKFLOW_RUN_FILE: its key, since it has no `name:`.
1143
+ # `branch_settled_var` reads that job's status; harness-run.yml's header
1144
+ # declares the mirror.
1145
+ RUN_JOB_NAME='run'
1146
+ # How far before the previous round's `collected_at` `round_collect` lists
1147
+ # from, absorbing runner-clock skew; the id check drops what it re-lists.
1148
+ ROUND_OVERLAP_SECS=300
471
1149
 
472
1150
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
473
1151
  # maximum payload for inputs is 65,535 characters."
@@ -478,6 +1156,7 @@ EXIT_OK=0
478
1156
  EXIT_USAGE=1
479
1157
  EXIT_REFUSED=2
480
1158
  EXIT_GH=3
1159
+ EXIT_PLACEMENT=4
481
1160
  # pause-requested only: the read succeeded and found no pause.
482
1161
  EXIT_NO_PAUSE=1
483
1162
 
@@ -486,15 +1165,25 @@ usage() {
486
1165
  echo "usage: remote-run.sh dispatch <branch> --engine <task|user_review|docs> [--resume none|answer|pause] [--answers-from <clar_dir> --indexes \"<n> ...\"] [--park-loop-clear] [--chain <n>] [--repo <root>]" >&2
487
1166
  echo " remote-run.sh pause <branch> [--repo <root>]" >&2
488
1167
  echo " remote-run.sh warm [--repo <root>]" >&2
489
- echo " remote-run.sh stop <branch> [--repo <root>]" >&2
1168
+ echo " remote-run.sh stop <branch> [--actor <login>] [--repo <root>]" >&2
490
1169
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
491
1170
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
1171
+ echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
492
1172
  echo " remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]" >&2
493
1173
  echo " remote-run.sh save <branch> <out_dir> [--repo <root>]" >&2
494
1174
  echo " remote-run.sh continue <branch> <bundle_dir> [--repo <root>]" >&2
495
1175
  echo " remote-run.sh poll [--repo <root>]" >&2
496
1176
  echo " remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]" >&2
497
1177
  echo " remote-run.sh run-created-at <run_id> [--repo <root>]" >&2
1178
+ echo " remote-run.sh start <branch> --prompt-file <file> [--repo <root>]" >&2
1179
+ echo " remote-run.sh review <branch> --review-file <file> [--allow-no-run] [--actor <login>] [--reviewers <login,login,...>] [--source <https-url>] [--repo <root>]" >&2
1180
+ echo " remote-run.sh trigger [--repo <root>]" >&2
1181
+ echo " remote-run.sh list [--repo <root>]" >&2
1182
+ echo " remote-run.sh discard <dir> [--repo <root>]" >&2
1183
+ echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
1184
+ echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1185
+ echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1186
+ echo " remote-run.sh control [--repo <root>]" >&2
498
1187
  [ "${verb-}" != save ] || exit "$EXIT_OK"
499
1188
  exit "$EXIT_USAGE"
500
1189
  }
@@ -505,7 +1194,22 @@ usage() {
505
1194
  GH_OUT=""
506
1195
  GH_ERR=""
507
1196
  gh_call() {
508
- local errfile status
1197
+ gh_run 0 "" "$@"
1198
+ }
1199
+
1200
+ # gh_call_token <token> <args...> — gh_call with GH_TOKEN set to <token> for
1201
+ # that one gh process only; every other call keeps the environment's token.
1202
+ gh_call_token() {
1203
+ local token="$1"
1204
+ shift
1205
+ gh_run 1 "$token" "$@"
1206
+ }
1207
+
1208
+ # gh_run <0|1> <token> <args...> — gh_call's body; with 1, GH_TOKEN is a prefix
1209
+ # assignment on the external command, never on a function, so it cannot leak.
1210
+ gh_run() {
1211
+ local with_token="$1" token="$2" errfile status
1212
+ shift 2
509
1213
  GH_OUT=""
510
1214
  GH_ERR=""
511
1215
  if ! command -v "$GH" >/dev/null 2>&1; then
@@ -513,7 +1217,11 @@ gh_call() {
513
1217
  return 127
514
1218
  fi
515
1219
  errfile=$(mktemp) || { GH_ERR="mktemp failed"; return 1; }
516
- GH_OUT=$("$GH" "$@" 2>"$errfile")
1220
+ if [ "$with_token" = 1 ]; then
1221
+ GH_OUT=$(GH_TOKEN="$token" "$GH" "$@" 2>"$errfile")
1222
+ else
1223
+ GH_OUT=$("$GH" "$@" 2>"$errfile")
1224
+ fi
517
1225
  status=$?
518
1226
  if [ "$status" -ne 0 ]; then
519
1227
  IFS= read -r GH_ERR <"$errfile" || :
@@ -545,7 +1253,7 @@ verb=""
545
1253
  verb="$1"
546
1254
  shift
547
1255
  case "$verb" in
548
- dispatch|pause|warm|stop|status|sync|restore|save|continue|poll|pause-requested|run-created-at) ;;
1256
+ 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) ;;
549
1257
  *) usage "unknown verb '$verb'" ;;
550
1258
  esac
551
1259
 
@@ -563,6 +1271,17 @@ chain="0"
563
1271
  repo_arg=""
564
1272
  since_arg=""
565
1273
  run_id_arg=""
1274
+ prompt_file=""
1275
+ review_file=""
1276
+ discard_dir=""
1277
+ discard_base=""
1278
+ report_event=""
1279
+ report_note=""
1280
+ actor_arg=""
1281
+ source_arg=""
1282
+ reviewers_arg=""
1283
+ allow_no_run=0
1284
+ pr_arg=""
566
1285
 
567
1286
  while [ "$#" -gt 0 ]; do
568
1287
  case "$1" in
@@ -586,20 +1305,60 @@ while [ "$#" -gt 0 ]; do
586
1305
  --park-loop-clear)
587
1306
  [ "$verb" = dispatch ] || usage "$1 is a dispatch option"
588
1307
  park_loop_clear=1; shift ;;
1308
+ --prompt-file)
1309
+ [ "$verb" = start ] || usage "$1 is a start option"
1310
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1311
+ prompt_file="$2"; shift 2 ;;
1312
+ --review-file)
1313
+ [ "$verb" = review ] || usage "$1 is a review option"
1314
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1315
+ review_file="$2"; shift 2 ;;
1316
+ --note)
1317
+ [ "$verb" = report ] || usage "$1 is a report option"
1318
+ [ "$#" -ge 2 ] || usage "$1 needs a value"
1319
+ report_note="$2"; shift 2 ;;
1320
+ --actor)
1321
+ [ "$verb" = stop ] || [ "$verb" = review ] || usage "$1 is a stop or review option"
1322
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1323
+ actor_arg="$2"; shift 2 ;;
1324
+ --source)
1325
+ [ "$verb" = review ] || usage "$1 is a review option"
1326
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1327
+ source_arg="$2"; shift 2 ;;
1328
+ --reviewers)
1329
+ [ "$verb" = review ] || usage "$1 is a review option"
1330
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1331
+ reviewers_arg="$2"; shift 2 ;;
1332
+ --allow-no-run)
1333
+ [ "$verb" = review ] || usage "$1 is a review option"
1334
+ allow_no_run=1; shift ;;
1335
+ --pr)
1336
+ [ "$verb" = collect ] || usage "$1 is a collect option"
1337
+ [ "$#" -ge 2 ] || usage "$1 needs a value"
1338
+ case "$2" in
1339
+ ''|*[!0-9]*|0*) usage "--pr needs a positive pull request number" ;;
1340
+ esac
1341
+ pr_arg="$2"; shift 2 ;;
589
1342
  -*)
590
1343
  usage "unknown option '$1'" ;;
591
1344
  *)
592
- [ "$verb" != warm ] && [ "$verb" != poll ] || usage "$verb takes no branch"
1345
+ [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != trigger ] \
1346
+ && [ "$verb" != list ] && [ "$verb" != control ] || usage "$verb takes no branch"
593
1347
  if [ "$verb" = run-created-at ]; then
594
1348
  [ -z "$run_id_arg" ] || usage "unexpected argument '$1'"
595
1349
  run_id_arg="$1"
1350
+ elif [ "$verb" = discard ]; then
1351
+ [ -z "$discard_dir" ] || usage "unexpected argument '$1'"
1352
+ discard_dir="$1"
1353
+ elif [ "$verb" = report ] && [ -z "$report_event" ]; then
1354
+ report_event="$1"
596
1355
  elif [ -z "$branch" ]; then
597
1356
  branch="$1"
598
1357
  elif [ "$verb" = pause-requested ] && [ -z "$since_arg" ]; then
599
1358
  since_arg="$1"
600
- elif [ "$verb" = save ] && [ -z "$out_dir" ]; then
1359
+ elif { [ "$verb" = save ] || [ "$verb" = fetch ]; } && [ -z "$out_dir" ]; then
601
1360
  out_dir="$1"
602
- elif [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
1361
+ elif { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
603
1362
  bundle_dir="$1"
604
1363
  else
605
1364
  usage "unexpected argument '$1'"
@@ -608,10 +1367,39 @@ while [ "$#" -gt 0 ]; do
608
1367
  esac
609
1368
  done
610
1369
 
611
- if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ]; then
1370
+ if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ] && [ "$verb" != trigger ] \
1371
+ && [ "$verb" != list ] && [ "$verb" != discard ] && [ "$verb" != control ]; then
612
1372
  valid_branch "$branch" || usage "$verb needs a <branch>"
613
1373
  fi
614
1374
 
1375
+ if [ "$verb" = discard ] && [ -z "$discard_dir" ]; then
1376
+ usage "discard needs a <dir>"
1377
+ fi
1378
+
1379
+ # The event lands in the comment marker, so it is a word.
1380
+ if [ "$verb" = report ] && ! [[ "$report_event" =~ ^[a-z][a-z_]*$ ]]; then
1381
+ usage "report needs an <event> of lowercase letters and underscores"
1382
+ fi
1383
+
1384
+ # The actor lands in the stop or round comment, so it is a login: the trigger's shape.
1385
+ if [ -n "$actor_arg" ] && ! [[ "$actor_arg" =~ ^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$ ]]; then
1386
+ usage "--actor needs a GitHub login"
1387
+ fi
1388
+
1389
+ # The reviewers land in the round comment, so each is a login.
1390
+ if [ -n "$reviewers_arg" ]; then
1391
+ [[ ",$reviewers_arg," =~ ^(,[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?)+,$ ]] \
1392
+ || usage "--reviewers needs comma-separated GitHub logins"
1393
+ fi
1394
+
1395
+ # The source lands in the round comment as a link.
1396
+ if [ -n "$source_arg" ]; then
1397
+ case "$source_arg" in
1398
+ https://*) ;;
1399
+ *) usage "--source needs an https:// URL" ;;
1400
+ esac
1401
+ fi
1402
+
615
1403
  if [ "$verb" = pause-requested ]; then
616
1404
  case "$since_arg" in
617
1405
  ''|*[!0-9]*) usage "pause-requested needs a <since_epoch> that is a non-negative integer" ;;
@@ -626,14 +1414,26 @@ if [ "$verb" = run-created-at ]; then
626
1414
  esac
627
1415
  fi
628
1416
 
629
- if [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
630
- usage "continue needs a <bundle_dir>"
1417
+ if { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
1418
+ usage "$verb needs a <bundle_dir>"
631
1419
  fi
632
1420
 
633
1421
  if [ "$verb" = save ] && [ -z "$out_dir" ]; then
634
1422
  usage "save needs an <out_dir>"
635
1423
  fi
636
1424
 
1425
+ if [ "$verb" = fetch ] && [ -z "$out_dir" ]; then
1426
+ usage "fetch needs an <out_dir>"
1427
+ fi
1428
+
1429
+ if [ "$verb" = review ] && [ -z "$review_file" ]; then
1430
+ usage "review needs --review-file"
1431
+ fi
1432
+
1433
+ if [ "$verb" = start ] && [ -z "$prompt_file" ]; then
1434
+ usage "start needs --prompt-file"
1435
+ fi
1436
+
637
1437
  if [ "$verb" = restore ]; then
638
1438
  [ "$resume_given" -eq 1 ] || usage "restore needs --resume"
639
1439
  case "$resume" in
@@ -673,17 +1473,20 @@ fi
673
1473
  # The repository and its configuration.
674
1474
  # ---------------------------------------------------------------------------
675
1475
 
676
- # setup_fail <message> — a configuration problem: exit 1, except for save.
1476
+ # setup_fail <message> — a configuration problem: exit 1, except for save,
1477
+ # report, deliver and collect, which never fail the step that calls them.
677
1478
  setup_fail() {
678
1479
  echo "remote-run.sh: $1" >&2
679
- [ "$verb" != save ] || exit "$EXIT_OK"
1480
+ [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != deliver ] && [ "$verb" != collect ] || exit "$EXIT_OK"
680
1481
  exit "$EXIT_USAGE"
681
1482
  }
682
1483
 
683
1484
  if [ -n "$repo_arg" ]; then
684
1485
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
685
1486
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
686
- || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ]; then
1487
+ || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
1488
+ || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
1489
+ || [ "$verb" = control ]; then
687
1490
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
688
1491
  else
689
1492
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -691,13 +1494,20 @@ fi
691
1494
 
692
1495
  hr_config_load "$root" || :
693
1496
  registry=""
1497
+ status_no_record=0
694
1498
  case "$verb" in
695
- restore|save|continue|poll)
1499
+ restore|save|continue|poll|discard)
696
1500
  hr_state_path "$root" >/dev/null || setup_fail "cannot resolve '$root/harness.config.json'"
697
1501
  ;;
698
1502
  pause-requested|run-created-at)
699
1503
  # Read verbs: no gate, and nothing of the configuration is read.
700
1504
  ;;
1505
+ trigger|control)
1506
+ # Gates itself, after reading the event, so a refusal can still be commented.
1507
+ ;;
1508
+ report|deliver|collect)
1509
+ # Gates itself (`forge_on`) and exits 0 on every outcome.
1510
+ ;;
701
1511
  status|sync)
702
1512
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || {
703
1513
  echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
@@ -705,7 +1515,18 @@ case "$verb" in
705
1515
  }
706
1516
  # Tested with -f first: hr_registry_get creates an absent registry, and
707
1517
  # status writes nothing.
708
- if [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch" execution)" != github-actions ]; then
1518
+ if [ "$verb" = status ] && { [ ! -f "$registry" ] || [ -z "$(hr_registry_get "$registry" "$branch" branch)" ]; }; then
1519
+ # No record: GitHub alone answers, behind the sending verbs' gate.
1520
+ status_no_record=1
1521
+ target=$(hr_execution_target "$root") || {
1522
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json' (or execution.target is outside its enum)" >&2
1523
+ exit "$EXIT_USAGE"
1524
+ }
1525
+ if [ "$target" != github-actions ]; then
1526
+ echo "remote-run.sh: refused, nothing sent: execution.target is '$target', not github-actions" >&2
1527
+ exit "$EXIT_REFUSED"
1528
+ fi
1529
+ elif [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch" execution)" != github-actions ]; then
709
1530
  echo "remote-run.sh: refused, nothing written: the local record of '$branch' does not carry execution: github-actions" >&2
710
1531
  exit "$EXIT_REFUSED"
711
1532
  fi
@@ -727,6 +1548,26 @@ case "$bundle_dir" in
727
1548
  ''|/*) ;;
728
1549
  *) bundle_dir="${PWD-.}/$bundle_dir" ;;
729
1550
  esac
1551
+ case "$prompt_file" in
1552
+ ''|/*) ;;
1553
+ *) prompt_file="${PWD-.}/$prompt_file" ;;
1554
+ esac
1555
+ case "$review_file" in
1556
+ ''|/*) ;;
1557
+ *) review_file="${PWD-.}/$review_file" ;;
1558
+ esac
1559
+ case "$answers_from" in
1560
+ ''|/*) ;;
1561
+ *) answers_from="${PWD-.}/$answers_from" ;;
1562
+ esac
1563
+ if [ "$verb" = fetch ]; then
1564
+ case "$out_dir" in
1565
+ /*) ;;
1566
+ *) out_dir="${PWD-.}/$out_dir" ;;
1567
+ esac
1568
+ fi
1569
+ # Kept apart from <dir>, so the library's character tests see it as typed.
1570
+ [ "$verb" != discard ] || discard_base="${PWD-.}"
730
1571
 
731
1572
  cd "$root" || setup_fail "cannot enter '$root'"
732
1573
 
@@ -734,6 +1575,9 @@ cd "$root" || setup_fail "cannot enter '$root'"
734
1575
  # The verbs.
735
1576
  # ---------------------------------------------------------------------------
736
1577
 
1578
+ # Appended to a failed dispatch's message; `start` sets it once its branch is pushed.
1579
+ dispatch_fail_note=""
1580
+
737
1581
  verb_dispatch() {
738
1582
  local answers="" value n file payload
739
1583
  local -a inputs
@@ -770,8 +1614,19 @@ verb_dispatch() {
770
1614
  exit "$EXIT_REFUSED"
771
1615
  fi
772
1616
 
773
- gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed"
1617
+ gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed$dispatch_fail_note"
774
1618
  echo "remote-run.sh: dispatched action=run engine=$engine resume=$resume for $branch"
1619
+
1620
+ # A user's resume: the main checkout's remote record, when one exists, is
1621
+ # running now.
1622
+ if [ "$((10#$chain))" -eq 0 ] && { [ "$resume" = answer ] || [ "$resume" = pause ]; }; then
1623
+ local reg
1624
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
1625
+ if remote_record_exists "$reg"; then
1626
+ hr_registry_set "$reg" "$branch" status running resumed_at "$(date '+%Y-%m-%dT%H:%M:%S')" resume_kind "$resume" \
1627
+ || echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
1628
+ fi
1629
+ fi
775
1630
  }
776
1631
 
777
1632
  verb_pause() {
@@ -822,6 +1677,11 @@ verb_stop() {
822
1677
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
823
1678
  fi
824
1679
  echo "remote-run.sh: stopped $branch"
1680
+ if [ -n "$actor_arg" ]; then
1681
+ forge_report stopped "$branch" "Stopped by @$actor_arg."
1682
+ else
1683
+ forge_report stopped "$branch" "Stopped from a local \`remote-run.sh stop\`."
1684
+ fi
825
1685
  }
826
1686
 
827
1687
  # list_runs — the branch's runs of the workflow into GH_OUT; exits 3 on failure.
@@ -921,6 +1781,55 @@ set_many_or_fail() {
921
1781
  }
922
1782
  }
923
1783
 
1784
+ # open_questions_in <bundle_dir> — OPEN_QUESTIONS: the space-separated <n>,
1785
+ # ascending, of every top-level `clarifications/<branch>/question_<n>.md` in the
1786
+ # bundle with no `answer_<n>.md` beside it.
1787
+ OPEN_QUESTIONS=""
1788
+ open_questions_in() {
1789
+ local clar f n
1790
+ OPEN_QUESTIONS=""
1791
+ hr_remote_names_var
1792
+ clar="$1/$HR_REMOTE_CLARIFY_DIR/$branch"
1793
+ for f in "$clar"/question_*.md; do
1794
+ [ -f "$f" ] || continue
1795
+ n="${f##*/question_}"
1796
+ n="${n%.md}"
1797
+ [[ "$n" =~ ^[0-9]+$ ]] || continue
1798
+ [ -e "$clar/answer_$n.md" ] || OPEN_QUESTIONS="$OPEN_QUESTIONS $n"
1799
+ done
1800
+ if [ -n "$OPEN_QUESTIONS" ]; then
1801
+ OPEN_QUESTIONS=$(printf '%s\n' $OPEN_QUESTIONS | sort -n | tr '\n' ' ')
1802
+ OPEN_QUESTIONS="${OPEN_QUESTIONS% }"
1803
+ fi
1804
+ }
1805
+
1806
+ # status_from_github — `status` with no local record: the newest run's state
1807
+ # read through `remote_state` into a temporary directory removed on exit.
1808
+ status_tmp=""
1809
+ status_from_github() {
1810
+ local n
1811
+ if [ -z "$(titled_runs "harness run $branch" | jq -c '.[0] // empty')" ]; then
1812
+ echo "remote-run.sh: no local record, and no run titled 'harness run $branch' on GitHub"
1813
+ return 0
1814
+ fi
1815
+ echo "remote-run.sh: no local record; state from GitHub (newest run):"
1816
+ status_tmp=$(mktemp -d) || { echo "remote-run.sh: cannot create a temporary directory" >&2; exit "$EXIT_USAGE"; }
1817
+ trap 'rm -rf "$status_tmp"' EXIT
1818
+ remote_state "$status_tmp"
1819
+ printf ' state: %s\n' "$RS_STATE"
1820
+ printf ' pause_reason: %s\n' "$RS_PAUSE_REASON"
1821
+ printf ' detail: %s\n' "$RS_DETAIL"
1822
+ printf ' engine: %s\n' "$RS_ENGINE"
1823
+ printf ' run_url: %s\n' "$RS_RUN_URL"
1824
+ if [ "$RS_BUNDLE" -eq 1 ]; then
1825
+ open_questions_in "$status_tmp"
1826
+ for n in $OPEN_QUESTIONS; do
1827
+ echo "remote-run.sh: open question question_$n.md"
1828
+ grep -E '^## Q[0-9]+' "$status_tmp/$HR_REMOTE_CLARIFY_DIR/$branch/question_$n.md" | sed 's/^/ /'
1829
+ done
1830
+ fi
1831
+ }
1832
+
924
1833
  verb_status() {
925
1834
  local runs finished_id synced_id field
926
1835
  list_runs
@@ -929,6 +1838,10 @@ verb_status() {
929
1838
  printf '%s' "$runs" | jq -r --argjson n "$STATUS_RUNS_SHOWN" '
930
1839
  if length == 0 then " (none)" else
931
1840
  .[:$n][] | " \(.databaseId) \(.displayTitle) \(.status)/\(.conclusion // "") \(.createdAt) \(.url)" end'
1841
+ if [ "$status_no_record" -eq 1 ]; then
1842
+ status_from_github
1843
+ return 0
1844
+ fi
932
1845
  echo "remote-run.sh: local record (last synced):"
933
1846
  for field in status pause_reason remote_run_url remote_synced_at; do
934
1847
  printf ' %s: %s\n' "$field" "$(hr_registry_get "$registry" "$branch" "$field")"
@@ -957,78 +1870,215 @@ sync_expired() {
957
1870
  echo "remote-run.sh: $line"
958
1871
  }
959
1872
 
960
- verb_sync() {
961
- local worktree runs newest id state url synced_id now older download status_file
962
- local status reason detail resume_at cycles
963
- worktree=$(hr_registry_get "$registry" "$branch" worktree)
964
- if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
965
- echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
966
- exit "$EXIT_REFUSED"
967
- fi
968
- list_runs
969
- runs=$(titled_runs "harness run $branch") || runs='[]'
970
- newest=$(printf '%s' "$runs" | jq -c '.[0] // empty')
1873
+ # remote_state <download_dir> [<applied_run_id>] — the one derivation of a
1874
+ # branch's newest remote state, from list_runs' answer in GH_OUT; `sync`,
1875
+ # `fetch`, `review` and `status` with no local record all call it. RS_STATE is `none` (no `harness run
1876
+ # <branch>` run listed), `running` (the newest is not `completed`), `applied`
1877
+ # (its id is <applied_run_id>: `sync`'s case 1, decided there), or the state
1878
+ # of `sync`'s cases 2-5, which it derives in that order. A bundle is downloaded
1879
+ # into <download_dir> — `sync`'s per-run directory under the main checkout when
1880
+ # empty — skipped when that directory already holds its status.json, and
1881
+ # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
1882
+ # existing for case 4. <finished> 1 reads the newest run as finished whatever
1883
+ # its `status`: `branch_settled_var` passes it once that run's `run` job has
1884
+ # completed. Exits 3 when gh fails, 2 for an unrecognised bundle.
1885
+ RS_RUNS=""
1886
+ RS_RUN_ID=""
1887
+ RS_RUN_URL=""
1888
+ RS_GH_STATUS=""
1889
+ RS_STATE=""
1890
+ RS_PAUSE_REASON=""
1891
+ RS_DETAIL=""
1892
+ RS_ENGINE=""
1893
+ RS_USAGE_RESUME_AT=""
1894
+ RS_PARK_LOOP_CYCLES=""
1895
+ RS_BUNDLE=0
1896
+ RS_DOWNLOAD=""
1897
+ remote_state() {
1898
+ local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
1899
+ RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
1900
+ RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
1901
+ RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
1902
+ RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
1903
+ newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
971
1904
  if [ -z "$newest" ]; then
972
- echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
1905
+ RS_STATE=none
973
1906
  return 0
974
1907
  fi
975
- id=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
976
- state=$(printf '%s' "$newest" | jq -r '.status // ""')
977
- url=$(printf '%s' "$newest" | jq -r '.url // ""')
978
- now=$(date +%s)
979
-
980
- if [ "$state" != completed ]; then
981
- set_many_or_fail status running remote_synced_at "$now"
982
- echo "remote-run.sh: run $id of $branch is $state; the record is running, nothing downloaded"
1908
+ RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
1909
+ RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
1910
+ RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
1911
+ if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
1912
+ RS_STATE=running
983
1913
  return 0
984
1914
  fi
985
-
986
- synced_id=$(hr_registry_get "$registry" "$branch" remote_run_id)
987
- # Case 1 — already applied. A record still waiting on this run's bundle is
988
- # re-checked: once it expires, the job can no longer take an answer.
989
- if [ "$id" = "$synced_id" ]; then
990
- case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
991
- paused/expired) ;;
992
- parked/*|park_loop/*|paused/*)
993
- bundle_state "$id"
994
- if [ "$BUNDLE_STATE" = expired ]; then
995
- sync_expired "$id" "$url" "$now"
996
- return 0
997
- fi
998
- ;;
999
- esac
1000
- set_or_fail remote_synced_at "$now"
1001
- echo "remote-run.sh: run $id of $branch is the one last synced; the mirror is current"
1915
+ if [ -n "$applied" ] && [ "$RS_RUN_ID" = "$applied" ]; then
1916
+ RS_STATE=applied
1002
1917
  return 0
1003
1918
  fi
1004
1919
 
1005
- # Case 2 — a newer run whose bundle has expired.
1006
- bundle_state "$id"
1920
+ # Case 2 — its bundle has expired.
1921
+ bundle_state "$RS_RUN_ID"
1007
1922
  if [ "$BUNDLE_STATE" = expired ]; then
1008
- sync_expired "$id" "$url" "$now"
1923
+ RS_STATE=paused
1924
+ RS_PAUSE_REASON=expired
1925
+ RS_DETAIL=$(expired_line "$RS_RUN_ID")
1009
1926
  return 0
1010
1927
  fi
1011
1928
 
1012
- # Case 3 — a newer run with a bundle.
1929
+ # Case 3 — a bundle.
1013
1930
  if [ "$BUNDLE_STATE" = present ]; then
1014
1931
  hr_remote_names_var
1015
- download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$id") || {
1016
- echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
1017
- exit "$EXIT_USAGE"
1018
- }
1932
+ if [ -z "$download" ]; then
1933
+ download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$RS_RUN_ID") || {
1934
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
1935
+ exit "$EXIT_USAGE"
1936
+ }
1937
+ fi
1938
+ RS_DOWNLOAD="$download"
1019
1939
  status_file="$download/$HR_REMOTE_STATUS_FILE"
1020
1940
  if [ ! -f "$status_file" ]; then
1021
1941
  mkdir -p "$download" || { echo "remote-run.sh: cannot create '$download'" >&2; exit "$EXIT_USAGE"; }
1022
- gh_call run download "$id" -n "$STATE_ARTIFACT_NAME" -D "$download" || gh_fail "downloading the bundle of run $id failed"
1942
+ gh_call run download "$RS_RUN_ID" -n "$STATE_ARTIFACT_NAME" -D "$download" || gh_fail "downloading the bundle of run $RS_RUN_ID failed"
1023
1943
  fi
1024
- status=$(hr_remote_status_get "$status_file" status) || status=""
1025
- case "$status" in
1944
+ RS_BUNDLE=1
1945
+ RS_STATE=$(hr_remote_status_get "$status_file" status) || RS_STATE=""
1946
+ case "$RS_STATE" in
1026
1947
  running|parked|park_loop|paused|completed|failed) ;;
1027
1948
  *)
1028
1949
  echo "remote-run.sh: refused, nothing written: the bundle in '$download' is unrecognised" >&2
1029
1950
  exit "$EXIT_REFUSED"
1030
1951
  ;;
1031
1952
  esac
1953
+ RS_PAUSE_REASON=$(hr_remote_status_get "$status_file" pause_reason) || RS_PAUSE_REASON=""
1954
+ RS_DETAIL=$(hr_remote_status_get "$status_file" detail) || RS_DETAIL=""
1955
+ if [ "$RS_STATE" = running ]; then
1956
+ RS_STATE=paused
1957
+ RS_PAUSE_REASON=killed
1958
+ RS_DETAIL="the job ended mid-run (its bundle still says running): $RS_RUN_URL"
1959
+ fi
1960
+ RS_USAGE_RESUME_AT=$(hr_remote_status_get "$status_file" usage_resume_at) || RS_USAGE_RESUME_AT=""
1961
+ RS_PARK_LOOP_CYCLES=$(hr_remote_status_get "$status_file" park_loop_cycles) || RS_PARK_LOOP_CYCLES=""
1962
+ RS_ENGINE=$(hr_remote_status_get "$status_file" engine) || RS_ENGINE=""
1963
+ return 0
1964
+ fi
1965
+
1966
+ # Case 4 — no bundle, while some bundle exists.
1967
+ if [ -n "$applied" ]; then
1968
+ bundle_exists=1
1969
+ else
1970
+ for older in $(printf '%s' "$RS_RUNS" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
1971
+ if has_bundle "$older"; then bundle_exists=1; break; fi
1972
+ done
1973
+ fi
1974
+ if [ "$bundle_exists" -eq 1 ]; then
1975
+ RS_STATE=paused
1976
+ RS_PAUSE_REASON=killed
1977
+ RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
1978
+ return 0
1979
+ fi
1980
+
1981
+ # Case 5 — no bundle in any run.
1982
+ RS_STATE=failed
1983
+ RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
1984
+ return 0
1985
+ }
1986
+
1987
+ # branch_settled_var <download_dir> <allow_no_run 0|1> — the one settledness
1988
+ # test `review`, `control` and `collect` share: whether a user-review round may be placed
1989
+ # on the branch now. Lists the runs itself, then derives RS_* through
1990
+ # `remote_state`. The newest `harness run <branch>` run decides:
1991
+ # none listed settled only under <allow_no_run> 1 (RS_STATE `none`)
1992
+ # `completed` `remote_state` as ever
1993
+ # anything else its jobs are read: while no job named RUN_JOB_NAME exists
1994
+ # (queued) or that job is not `completed`, in flight as
1995
+ # `running`; once it is, the run's bundle decides as for a
1996
+ # finished run, since only a later job of that run is left
1997
+ # SETTLED is 1 for RS_STATE `completed` or `failed` (and `none` as above), else
1998
+ # 0. Exits 3 when gh fails, 2 for an unrecognised bundle, as `remote_state` does.
1999
+ SETTLED=0
2000
+ branch_settled_var() {
2001
+ local download="${1-}" allow="${2-0}" listed newest id status job finished=0
2002
+ SETTLED=0
2003
+ list_runs
2004
+ # `remote_state` reads the listing from GH_OUT, which the jobs call replaces.
2005
+ listed="$GH_OUT"
2006
+ newest=$(titled_runs "harness run $branch" | jq -c '.[0] // empty') || newest=""
2007
+ if [ -n "$newest" ]; then
2008
+ status=$(printf '%s' "$newest" | jq -r '.status // ""')
2009
+ if [ "$status" != completed ]; then
2010
+ id=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
2011
+ gh_call api "repos/{owner}/{repo}/actions/runs/$id/jobs" || gh_fail "reading the jobs of run $id failed"
2012
+ job=$(printf '%s' "$GH_OUT" | jq -r --arg n "$RUN_JOB_NAME" \
2013
+ '[.jobs[]? | select(.name == $n) | .status // ""] | first // ""' 2>/dev/null) || {
2014
+ GH_ERR="its job list is not the expected JSON"
2015
+ gh_fail "reading the jobs of run $id failed"
2016
+ }
2017
+ [ "$job" != completed ] || finished=1
2018
+ fi
2019
+ fi
2020
+ GH_OUT="$listed"
2021
+ remote_state "$download" "" "$finished"
2022
+ case "$RS_STATE" in
2023
+ completed|failed) SETTLED=1 ;;
2024
+ none) [ "$allow" != 1 ] || SETTLED=1 ;;
2025
+ esac
2026
+ return 0
2027
+ }
2028
+
2029
+ verb_sync() {
2030
+ local worktree id url synced_id now download
2031
+ local status reason detail
2032
+ worktree=$(hr_registry_get "$registry" "$branch" worktree)
2033
+ if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
2034
+ echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
2035
+ exit "$EXIT_REFUSED"
2036
+ fi
2037
+ list_runs
2038
+ synced_id=$(hr_registry_get "$registry" "$branch" remote_run_id)
2039
+ now=$(date +%s)
2040
+ remote_state "" "$synced_id"
2041
+ id="$RS_RUN_ID"
2042
+ url="$RS_RUN_URL"
2043
+
2044
+ case "$RS_STATE" in
2045
+ none)
2046
+ echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
2047
+ return 0
2048
+ ;;
2049
+ running)
2050
+ set_many_or_fail status running remote_synced_at "$now"
2051
+ echo "remote-run.sh: run $id of $branch is $RS_GH_STATUS; the record is running, nothing downloaded"
2052
+ return 0
2053
+ ;;
2054
+ applied)
2055
+ # Case 1 — already applied. A record still waiting on this run's bundle
2056
+ # is re-checked: once it expires, the job can no longer take an answer.
2057
+ case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
2058
+ paused/expired) ;;
2059
+ parked/*|park_loop/*|paused/*)
2060
+ bundle_state "$id"
2061
+ if [ "$BUNDLE_STATE" = expired ]; then
2062
+ sync_expired "$id" "$url" "$now"
2063
+ return 0
2064
+ fi
2065
+ ;;
2066
+ esac
2067
+ set_or_fail remote_synced_at "$now"
2068
+ echo "remote-run.sh: run $id of $branch is the one last synced; the mirror is current"
2069
+ return 0
2070
+ ;;
2071
+ esac
2072
+
2073
+ # Case 2 — a newer run whose bundle has expired.
2074
+ if [ "$RS_PAUSE_REASON" = expired ] && [ "$RS_BUNDLE" -eq 0 ]; then
2075
+ sync_expired "$id" "$url" "$now"
2076
+ return 0
2077
+ fi
2078
+
2079
+ # Case 3 — a newer run with a bundle.
2080
+ if [ "$RS_BUNDLE" -eq 1 ]; then
2081
+ download="$RS_DOWNLOAD"
1032
2082
  hr_remote_bundle_restore "$download" "$worktree" "$branch" mirror
1033
2083
  case $? in
1034
2084
  0) ;;
@@ -1043,44 +2093,33 @@ verb_sync() {
1043
2093
  || { echo "remote-run.sh: copying the run log to '$remote_log' failed" >&2; exit "$EXIT_USAGE"; }
1044
2094
  fi
1045
2095
  fi
1046
- reason=$(hr_remote_status_get "$status_file" pause_reason) || reason=""
1047
- detail=$(hr_remote_status_get "$status_file" detail) || detail=""
1048
- if [ "$status" = running ]; then
1049
- status=paused
1050
- reason=killed
1051
- detail="the job ended mid-run (its bundle still says running): $url"
1052
- fi
2096
+ status="$RS_STATE"
2097
+ reason="$RS_PAUSE_REASON"
2098
+ detail="$RS_DETAIL"
1053
2099
  [ -n "$detail" ] || detail="synced from $url"
1054
- resume_at=$(hr_remote_status_get "$status_file" usage_resume_at) || resume_at=""
1055
- cycles=$(hr_remote_status_get "$status_file" park_loop_cycles) || cycles=""
1056
- set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$resume_at" \
1057
- park_loop_cycles "$cycles" remote_run_id "$id" remote_run_url "$url" \
1058
- remote_detail "$detail" remote_synced_at "$now"
2100
+ if valid_engine "$RS_ENGINE"; then
2101
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
2102
+ park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
2103
+ remote_detail "$detail" remote_synced_at "$now" engine "$RS_ENGINE"
2104
+ else
2105
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
2106
+ park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
2107
+ remote_detail "$detail" remote_synced_at "$now"
2108
+ fi
1059
2109
  echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
1060
2110
  return 0
1061
2111
  fi
1062
2112
 
1063
2113
  # Case 4 — a newer run with no bundle, while some bundle exists.
1064
- local bundle_exists=0
1065
- if [ -n "$synced_id" ]; then
1066
- bundle_exists=1
1067
- else
1068
- for older in $(printf '%s' "$runs" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
1069
- if has_bundle "$older"; then bundle_exists=1; break; fi
1070
- done
1071
- fi
1072
- if [ "$bundle_exists" -eq 1 ]; then
2114
+ if [ "$RS_STATE" = paused ]; then
1073
2115
  set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
1074
- remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url" \
1075
- remote_synced_at "$now"
2116
+ remote_detail "$RS_DETAIL" remote_synced_at "$now"
1076
2117
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
1077
2118
  return 0
1078
2119
  fi
1079
2120
 
1080
2121
  # Case 5 — no bundle in any run.
1081
- set_many_or_fail status failed \
1082
- remote_detail "no run of $branch ever uploaded a state bundle; newest: $url" \
1083
- remote_synced_at "$now"
2122
+ set_many_or_fail status failed remote_detail "$RS_DETAIL" remote_synced_at "$now"
1084
2123
  echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
1085
2124
  }
1086
2125
 
@@ -1094,26 +2133,71 @@ restore_refuse() {
1094
2133
  exit "$EXIT_REFUSED"
1095
2134
  }
1096
2135
 
2136
+ # lineage_commits_var <checkout> — the branch's current lineage, read from refs
2137
+ # alone: never a fetch, never gh, nothing on stdout. A run is of the current
2138
+ # lineage when its `headSha` is a commit reachable from HEAD and not from
2139
+ # `origin/<defaultBranch>`. The list is complete because `harness-run.yml`
2140
+ # checks out with `fetch-depth: 0`. It survives the branch's own history
2141
+ # edits because `refresh-branch.sh` merges and never rebases and
2142
+ # `push-branch.sh` never forces, so every own run's `headSha` stays an ancestor
2143
+ # of HEAD; a deleted, unmerged branch's commits are not ancestors of a branch
2144
+ # recreated under its name. An empty list leaves the lineage unbounded.
2145
+ # 0: LINEAGE_COMMITS holds the newline-separated full SHAs. 1: it is empty and
2146
+ # LINEAGE_WHY names the reason.
2147
+ LINEAGE_COMMITS=""
2148
+ LINEAGE_WHY=""
2149
+ lineage_commits_var() {
2150
+ local checkout="${1-}" default
2151
+ LINEAGE_COMMITS=""
2152
+ LINEAGE_WHY=""
2153
+ default=$(hr_default_branch "$checkout") && [ -n "$default" ] || {
2154
+ LINEAGE_WHY="the configuration could not be read"
2155
+ return 1
2156
+ }
2157
+ git -C "$checkout" rev-parse --verify --quiet "refs/remotes/origin/$default^{commit}" >/dev/null 2>&1 || {
2158
+ LINEAGE_WHY="origin/$default is not present"
2159
+ return 1
2160
+ }
2161
+ LINEAGE_COMMITS=$(git -C "$checkout" rev-list "refs/remotes/origin/$default..HEAD" 2>/dev/null) || LINEAGE_COMMITS=""
2162
+ [ -n "$LINEAGE_COMMITS" ] || {
2163
+ LINEAGE_WHY="HEAD carries no commit beyond origin/$default"
2164
+ return 1
2165
+ }
2166
+ return 0
2167
+ }
2168
+
1097
2169
  # previous_bundle_run — PREV_RUN_ID is the newest finished `harness run
1098
2170
  # <branch>` run, other than this job's own, carrying a state artifact, and
1099
- # PREV_RUN_STATE is `present`, `expired` or empty when no run carries one. A
1100
- # run with no artifact is walked past; an expired one stops the walk, because
2171
+ # PREV_RUN_STATE is `present`, `expired` or empty when no run carries one. When
2172
+ # `lineage_commits_var` bounds the lineage, a run whose `headSha` is not in it
2173
+ # (or that has none) is dropped before the walk and counted in LINEAGE_SKIPPED.
2174
+ # A run with no artifact is walked past; an expired one stops the walk, because
1101
2175
  # an older copy is staler state. Exits 3 when gh fails.
1102
2176
  PREV_RUN_ID=""
1103
2177
  PREV_RUN_STATE=""
2178
+ LINEAGE_SKIPPED=0
1104
2179
  previous_bundle_run() {
1105
- local ids id
2180
+ local ids id out bounded=0
1106
2181
  PREV_RUN_ID=""
1107
2182
  PREV_RUN_STATE=""
2183
+ LINEAGE_SKIPPED=0
2184
+ lineage_commits_var "$root" && bounded=1
1108
2185
  gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" \
1109
- --json databaseId,displayTitle,status,createdAt --limit "$RUN_LIST_LIMIT" \
2186
+ --json databaseId,displayTitle,status,createdAt,headSha --limit "$RUN_LIST_LIMIT" \
1110
2187
  || gh_fail "listing the runs of '$branch' failed"
1111
- ids=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg self "${GITHUB_RUN_ID-}" '
1112
- [.[] | select(.displayTitle == $t and .status == "completed" and (.databaseId | tostring) != $self)]
1113
- | sort_by([.createdAt, .databaseId]) | reverse | .[].databaseId | tostring' 2>/dev/null) || {
2188
+ # jq 1.5: membership by `any(gen; cond)`, not `index` / `IN`.
2189
+ out=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg self "${GITHUB_RUN_ID-}" \
2190
+ --arg bounded "$bounded" --arg lineage "$LINEAGE_COMMITS" '
2191
+ ($lineage | split("\n")) as $l
2192
+ | [.[] | select(.displayTitle == $t and .status == "completed" and (.databaseId | tostring) != $self)] as $done
2193
+ | [$done[] | select($bounded != "1" or ((.headSha // "") as $h | any($l[]; . == $h)))] as $kept
2194
+ | ((($done | length) - ($kept | length)) | tostring),
2195
+ ($kept | sort_by([.createdAt, .databaseId]) | reverse | .[].databaseId | tostring)' 2>/dev/null) || {
1114
2196
  GH_ERR="its run list is not the expected JSON"
1115
2197
  gh_fail "listing the runs of '$branch' failed"
1116
2198
  }
2199
+ LINEAGE_SKIPPED=$(printf '%s\n' "$out" | head -n 1)
2200
+ ids=$(printf '%s\n' "$out" | tail -n +2)
1117
2201
  for id in $ids; do
1118
2202
  bundle_state "$id"
1119
2203
  if [ "$BUNDLE_STATE" != none ]; then
@@ -1144,13 +2228,18 @@ verb_restore() {
1144
2228
  previous_bundle_run
1145
2229
  id="$PREV_RUN_ID"
1146
2230
  hr_remote_names_var
2231
+ if [ -n "$LINEAGE_WHY" ]; then
2232
+ echo "remote-run.sh: the lineage of $branch is not bounded ($LINEAGE_WHY); every finished run of it is a candidate"
2233
+ elif [ "${LINEAGE_SKIPPED:-0}" -gt 0 ]; then
2234
+ echo "remote-run.sh: skipped $LINEAGE_SKIPPED finished run(s) of $branch from before its current lineage"
2235
+ fi
1147
2236
  if [ "$PREV_RUN_STATE" = expired ]; then
1148
2237
  [ "$resume" != answer ] \
1149
2238
  || restore_refuse "the state bundle of run $id expired on $BUNDLE_EXPIRES_AT, so its questions can no longer be answered here: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task; nothing written"
1150
2239
  echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts, the clarification history and any planning drafts not yet committed that it carried are lost; this job continues from the committed ledger"
1151
2240
  elif [ -z "$id" ]; then
1152
2241
  [ "$resume" != answer ] \
1153
- || restore_refuse "--resume answer, but no finished run of $branch carries a state bundle; nothing written"
2242
+ || restore_refuse "--resume answer, but no finished run of $branch's current lineage carries a state bundle; nothing written"
1154
2243
  echo "remote-run.sh: no previous bundle for $branch; this is its first job"
1155
2244
  else
1156
2245
  download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$id") \
@@ -1278,7 +2367,11 @@ verb_save() {
1278
2367
  return 0
1279
2368
  }
1280
2369
 
1281
- # notify <event> <branch> <detail> — one lifecycle notification; never fails.
2370
+ # notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
2371
+ # then `forge_report` of the same event; never fails. Every call site supplies
2372
+ # both texts: <detail> is the push notification's, slash commands included;
2373
+ # <forge_note> is the comment's, naming no slash command and no shell command,
2374
+ # and states only what happened, since `forge_report` adds the next action.
1282
2375
  notify() {
1283
2376
  if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
1284
2377
  HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
@@ -1287,6 +2380,8 @@ notify() {
1287
2380
  bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
1288
2381
  || echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
1289
2382
  echo "remote-run.sh: notified $1 for $2: $3"
2383
+ # stdin closed: `poll` calls this inside a loop reading its run list.
2384
+ forge_report "$1" "$2" "$4" </dev/null
1290
2385
  }
1291
2386
 
1292
2387
  this_run_url() {
@@ -1315,7 +2410,7 @@ ALL_RUNS_LISTED=0
1315
2410
  list_all_runs() {
1316
2411
  [ "$ALL_RUNS_LISTED" -eq 0 ] || return 0
1317
2412
  gh_call run list --workflow "$WORKFLOW_RUN_FILE" \
1318
- --json databaseId,headBranch,displayTitle,status,createdAt --limit "$ALL_RUNS_LIMIT" || return 1
2413
+ --json databaseId,headBranch,displayTitle,status,createdAt,url --limit "$ALL_RUNS_LIMIT" || return 1
1319
2414
  printf '%s' "$GH_OUT" | jq -e 'type == "array"' >/dev/null 2>&1 || {
1320
2415
  GH_ERR="its run list is not the expected JSON"
1321
2416
  return 1
@@ -1385,46 +2480,49 @@ valid_engine() {
1385
2480
 
1386
2481
  STOPPED_LINE="a 'harness stop' run is newer than its newest 'harness run' run"
1387
2482
  RESUME_HINT="/autonomous-sdlc-harness:branch-resume"
2483
+ # A `paused` report on a usage pause says the run resumes by itself; a note
2484
+ # saying the automatic resume failed carries the action instead.
2485
+ USAGE_RESUME_NOTE="Comment \`$COMMAND_HANDLE resume\` after the limit resets to continue."
1388
2486
 
1389
2487
  continue_redispatch() {
1390
2488
  local status_file="$1" engine_value
1391
2489
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
1392
- notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue."
2490
+ notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: the repository variable \`HARNESS_REMOTE_STOP\` is set; clear it, then resume."
1393
2491
  return 0
1394
2492
  fi
1395
2493
  remote_branch_stopped "$branch"
1396
2494
  case $? in
1397
2495
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
1398
- 2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue."; return 0 ;;
2496
+ 2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: whether the run was stopped could not be checked ($GH_ERR)."; return 0 ;;
1399
2497
  esac
1400
2498
  if ! max_chain_var; then
1401
- notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer."
2499
+ notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer." "Not continued: the repository variable \`HARNESS_MAX_CHAIN\` ('$MAX_CHAIN') is not a non-negative integer."
1402
2500
  return 0
1403
2501
  fi
1404
2502
  next_chain_var "$status_file"
1405
2503
  case $? in
1406
- 1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json."; return 0 ;;
1407
- 2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 0 ;;
2504
+ 1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json." "Not continued: the chain count in the run's status could not be read."; return 0 ;;
2505
+ 2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not continued: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 0 ;;
1408
2506
  esac
1409
2507
  engine_value=$(hr_remote_status_get "$status_file" engine) || engine_value=""
1410
2508
  if ! valid_engine "$engine_value"; then
1411
- notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs."
2509
+ notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs." "Not continued: the engine '$engine_value' in the run's status is not task, user_review or docs."
1412
2510
  return 0
1413
2511
  fi
1414
2512
  redispatch "$engine_value" "$NEXT_CHAIN" \
1415
- || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue."
2513
+ || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "$engine_value")." "Not continued: dispatching the next job failed ($REDISPATCH_ERR)."
1416
2514
  }
1417
2515
 
1418
2516
  continue_wait_poller() {
1419
2517
  remote_branch_stopped "$branch"
1420
2518
  case $? in
1421
2519
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
1422
- 2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue."; return 0 ;;
2520
+ 2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit was not scheduled: whether the run was stopped could not be checked ($GH_ERR). $USAGE_RESUME_NOTE"; return 0 ;;
1423
2521
  esac
1424
2522
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
1425
2523
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
1426
2524
  else
1427
- notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset."
2525
+ notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit could not be scheduled ($GH_ERR). $USAGE_RESUME_NOTE"
1428
2526
  fi
1429
2527
  }
1430
2528
 
@@ -1433,7 +2531,7 @@ verb_continue() {
1433
2531
  hr_remote_names_var
1434
2532
  status_file="$bundle_dir/$HR_REMOTE_STATUS_FILE"
1435
2533
  if [ ! -f "$status_file" ]; then
1436
- notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)"
2534
+ notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)" "The job stopped before the run started: $(this_run_url)."
1437
2535
  return 0
1438
2536
  fi
1439
2537
  decision=$(hr_remote_status_get "$status_file" decision) || decision=""
@@ -1441,7 +2539,7 @@ verb_continue() {
1441
2539
  continue) continue_redispatch "$status_file" ;;
1442
2540
  wait-poller) continue_wait_poller ;;
1443
2541
  stop) echo "remote-run.sh: decision stop for $branch; nothing to do" ;;
1444
- *) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" ;;
2542
+ *) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" "Not continued: the run's status carries no recognised decision." ;;
1445
2543
  esac
1446
2544
  return 0
1447
2545
  }
@@ -1622,12 +2720,12 @@ poll_branch() {
1622
2720
  fi
1623
2721
  next_chain_var "$POLL_STATUS_FILE"
1624
2722
  case $? in
1625
- 1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json."; return 1 ;;
1626
- 2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 1 ;;
2723
+ 1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json." "Not resumed after the usage limit: the chain count in the run's status could not be read."; return 1 ;;
2724
+ 2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not resumed after the usage limit: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 1 ;;
1627
2725
  esac
1628
2726
  engine_value=$(hr_remote_status_get "$POLL_STATUS_FILE" engine) || engine_value=""
1629
2727
  if ! valid_engine "$engine_value"; then
1630
- [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs."
2728
+ [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs." "Not resumed after the usage limit: the engine '$engine_value' in the run's status is not task, user_review or docs."
1631
2729
  return 1
1632
2730
  fi
1633
2731
  if [ "$may_dispatch" -eq 0 ]; then
@@ -1648,7 +2746,7 @@ poll_branch() {
1648
2746
  if [ "$failures" -ge "$((10#$POLL_MAX_FAILURES))" ] \
1649
2747
  || [ "$now" -gt "$((10#$at + 10#$POLL_GIVE_UP_MINUTES * 60))" ]; then
1650
2748
  poll_state_put "$branch" "$id" "$failures" 1
1651
- notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch."
2749
+ notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch; $(hr_github_resume_route "$branch" "$engine_value")." "Not resumed after the usage limit: dispatching the next job failed $failures times ($REDISPATCH_ERR), and the automatic resume has stopped."
1652
2750
  return 1
1653
2751
  fi
1654
2752
  poll_state_put "$branch" "$id" "$failures" ""
@@ -1691,7 +2789,7 @@ EOF
1691
2789
  # poll_recheck — after the disable: one fresh listing, evaluated without
1692
2790
  # dispatching; re-enables the poller when a branch became waiting meanwhile.
1693
2791
  poll_recheck() {
1694
- local b
2792
+ local b enable_err
1695
2793
  ALL_RUNS_LISTED=0
1696
2794
  if ! list_all_runs || ! poll_pass 0; then
1697
2795
  echo "remote-run.sh: poll: the re-check after disabling $WORKFLOW_RESUME_FILE could not list the runs ($GH_ERR); it stays disabled"
@@ -1704,8 +2802,10 @@ poll_recheck() {
1704
2802
  done
1705
2803
  return 0
1706
2804
  fi
2805
+ # Captured once: each notify's report overwrites GH_ERR.
2806
+ enable_err="$GH_ERR"
1707
2807
  for b in $POLL_WAITING; do
1708
- notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $b after the usage reset."
2808
+ notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($enable_err). Run $RESUME_HINT $b after the usage reset; $(hr_github_resume_route "$b" "")." "The automatic resume after the usage limit could not be scheduled ($enable_err). $USAGE_RESUME_NOTE"
1709
2809
  done
1710
2810
  }
1711
2811
 
@@ -1778,6 +2878,2436 @@ verb_run_created_at() {
1778
2878
  printf '%s\n' "$epoch"
1779
2879
  }
1780
2880
 
2881
+ # placement_fail <step> — exit 4, naming the step; nothing was dispatched.
2882
+ placement_fail() {
2883
+ echo "remote-run.sh: start of '$branch' failed at $1; nothing was dispatched" >&2
2884
+ exit "$EXIT_PLACEMENT"
2885
+ }
2886
+
2887
+ # start_remove_copy — remove the working copy and the local branch `start`
2888
+ # cut, and only those: `had_copy` / `had_branch` record what existed before the
2889
+ # cut. Idempotent; a failed step is one stderr line and never changes the exit
2890
+ # status already decided. `--force` because a failure exit may leave the placed,
2891
+ # uncommitted prompt, which is only a copy of --prompt-file.
2892
+ start_remove_copy() {
2893
+ if [ "$had_copy" -eq 0 ] && [ -e "$worktree" ]; then
2894
+ git -C "$root" worktree remove --force "$worktree" >/dev/null 2>&1 \
2895
+ || echo "remote-run.sh: could not remove the working copy '$worktree'" >&2
2896
+ fi
2897
+ git -C "$root" worktree prune >/dev/null 2>&1 \
2898
+ || echo "remote-run.sh: git worktree prune failed in '$root'" >&2
2899
+ if [ "$had_branch" -eq 0 ] && git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then
2900
+ git -C "$root" branch -D "$branch" >/dev/null 2>&1 \
2901
+ || echo "remote-run.sh: could not delete the local branch '$branch'" >&2
2902
+ fi
2903
+ return 0
2904
+ }
2905
+
2906
+ verb_start() {
2907
+ local protected=0 status=0 state_rel rel subject
2908
+ hr_branch_is_protected "$root" "$branch" || protected=$?
2909
+ case "$protected" in
2910
+ 0)
2911
+ echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
2912
+ exit "$EXIT_REFUSED" ;;
2913
+ 2)
2914
+ echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
2915
+ exit "$EXIT_REFUSED" ;;
2916
+ esac
2917
+ if [ ! -f "$prompt_file" ] || [ ! -r "$prompt_file" ]; then
2918
+ echo "remote-run.sh: refused, nothing written: the prompt file '$prompt_file' is not a readable regular file" >&2
2919
+ exit "$EXIT_REFUSED"
2920
+ fi
2921
+
2922
+ # Global, not local: the EXIT trap runs after this function's frame is gone.
2923
+ worktree=$(hr_worktree_dir "$root" "$branch") || placement_fail "resolving the working copy"
2924
+ had_copy=0
2925
+ [ ! -e "$worktree" ] || had_copy=1
2926
+ had_branch=0
2927
+ ! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
2928
+
2929
+ # The cut itself can leave a half-created copy, and every failure below exits.
2930
+ trap start_remove_copy EXIT
2931
+ bash "$script_dir/create-worktree.sh" --no-bootstrap "$branch" >&2 || status=$?
2932
+ [ "$status" -eq 0 ] || placement_fail "the branch cut (create-worktree.sh exited $status)"
2933
+
2934
+ state_rel=$(hr_state_dir "$worktree") || placement_fail "resolving the state directory in '$worktree'"
2935
+ [ -n "$state_rel" ] || placement_fail "resolving the state directory in '$worktree'"
2936
+ rel=$(hr_task_prompt_rel "$state_rel" "$branch")
2937
+ subject=$(hr_task_prompt_subject "$branch")
2938
+
2939
+ hr_place_artifact "$worktree" "$prompt_file" "$rel" || placement_fail "copying the prompt to '$worktree/$rel'"
2940
+ # 3 (nothing staged) cannot happen on a freshly cut branch, so it reads as 0.
2941
+ status=0
2942
+ hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
2943
+ [ "$status" -ne 1 ] || placement_fail "committing '$rel'"
2944
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
2945
+ || placement_fail "pushing $branch (origin/$branch is not HEAD)"
2946
+ start_remove_copy
2947
+ trap - EXIT
2948
+
2949
+ engine=task
2950
+ resume=none
2951
+ chain=0
2952
+ dispatch_fail_note="; $branch and its task prompt are already pushed to origin, so re-send with: remote-run.sh dispatch $branch --engine task"
2953
+ verb_dispatch
2954
+ echo "remote-run.sh: started $branch"
2955
+ }
2956
+
2957
+ # ---------------------------------------------------------------------------
2958
+ # `fetch` — the newest remote state of one branch, for the plugin commands.
2959
+ # ---------------------------------------------------------------------------
2960
+
2961
+ verb_fetch() {
2962
+ local open_questions="" bundle_dir=""
2963
+ if [ ! -d "$out_dir" ]; then
2964
+ echo "remote-run.sh: fetch needs an existing directory, not '$out_dir'" >&2
2965
+ exit "$EXIT_USAGE"
2966
+ fi
2967
+ if [ -n "$(ls -A "$out_dir" 2>/dev/null)" ]; then
2968
+ echo "remote-run.sh: fetch needs an empty directory, and '$out_dir' is not" >&2
2969
+ exit "$EXIT_USAGE"
2970
+ fi
2971
+ list_runs
2972
+ remote_state "$out_dir"
2973
+ if [ "$RS_BUNDLE" -eq 1 ]; then
2974
+ bundle_dir="$out_dir"
2975
+ open_questions_in "$out_dir"
2976
+ open_questions="$OPEN_QUESTIONS"
2977
+ fi
2978
+ printf 'run_id: %s\n' "$RS_RUN_ID"
2979
+ printf 'run_url: %s\n' "$RS_RUN_URL"
2980
+ printf 'run_status: %s\n' "$RS_GH_STATUS"
2981
+ printf 'state: %s\n' "$RS_STATE"
2982
+ printf 'pause_reason: %s\n' "$RS_PAUSE_REASON"
2983
+ printf 'engine: %s\n' "$RS_ENGINE"
2984
+ printf 'detail: %s\n' "$RS_DETAIL"
2985
+ printf 'open_questions: %s\n' "$open_questions"
2986
+ printf 'bundle_dir: %s\n' "$bundle_dir"
2987
+ }
2988
+
2989
+ # ---------------------------------------------------------------------------
2990
+ # `review` — a user review round placed on the branch tip, then dispatched.
2991
+ # ---------------------------------------------------------------------------
2992
+
2993
+ # review_fail <step> — exit 4, naming the step; nothing was dispatched.
2994
+ review_fail() {
2995
+ echo "remote-run.sh: review of '$branch' failed at $1; nothing was dispatched" >&2
2996
+ exit "$EXIT_PLACEMENT"
2997
+ }
2998
+
2999
+ # remote_record_exists <registry> — 0 when <registry> is a file holding a
3000
+ # record of the branch with `execution: github-actions`. Tested with -f first:
3001
+ # hr_registry_get creates an absent registry.
3002
+ remote_record_exists() {
3003
+ [ -n "${1-}" ] && [ -f "$1" ] && [ "$(hr_registry_get "$1" "$branch" execution)" = github-actions ]
3004
+ }
3005
+
3006
+ verb_review() {
3007
+ local protected=0 status=0 reg="" record_wt="" use_mirror=0 state_rel names name round max=0 next rel note pr pushed
3008
+ hr_branch_is_protected "$root" "$branch" || protected=$?
3009
+ case "$protected" in
3010
+ 0)
3011
+ echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
3012
+ exit "$EXIT_REFUSED" ;;
3013
+ 2)
3014
+ echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
3015
+ exit "$EXIT_REFUSED" ;;
3016
+ esac
3017
+ if [ ! -f "$review_file" ] || [ ! -r "$review_file" ]; then
3018
+ echo "remote-run.sh: refused, nothing written: the review file '$review_file' is not a readable regular file" >&2
3019
+ exit "$EXIT_REFUSED"
3020
+ fi
3021
+
3022
+ # An unsettled branch takes no review here: a local round's file exists only
3023
+ # on the caller's machine, so no later collection could pick it up.
3024
+ branch_settled_var "" "$allow_no_run"
3025
+ if [ "$SETTLED" -ne 1 ]; then
3026
+ case "$RS_STATE" in
3027
+ none)
3028
+ echo "remote-run.sh: refused, nothing written: no \`harness run $branch\` run on GitHub" >&2 ;;
3029
+ paused)
3030
+ if [ "$RS_PAUSE_REASON" = expired ]; then
3031
+ echo "remote-run.sh: refused, nothing written: $RS_DETAIL" >&2
3032
+ else
3033
+ echo "remote-run.sh: refused, nothing written: $branch is paused${RS_PAUSE_REASON:+ ($RS_PAUSE_REASON)} on GitHub; a review waits until its run is completed or failed" >&2
3034
+ fi ;;
3035
+ *)
3036
+ echo "remote-run.sh: refused, nothing written: $branch is $RS_STATE on GitHub; a review waits until its run is completed or failed" >&2 ;;
3037
+ esac
3038
+ exit "$EXIT_REFUSED"
3039
+ fi
3040
+
3041
+ # The copy: the remote record's mirror when it is on the branch, never
3042
+ # removed; else a copy this verb cuts and removes on every exit.
3043
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
3044
+ if remote_record_exists "$reg"; then
3045
+ record_wt=$(hr_registry_get "$reg" "$branch" worktree)
3046
+ if [ -n "$record_wt" ] && [ -d "$record_wt" ] \
3047
+ && [ "$(git -C "$record_wt" symbolic-ref --short HEAD 2>/dev/null)" = "$branch" ]; then
3048
+ use_mirror=1
3049
+ fi
3050
+ fi
3051
+ if [ "$use_mirror" -eq 1 ]; then
3052
+ worktree="$record_wt"
3053
+ else
3054
+ # Global, not local: the EXIT trap runs after this function's frame is gone.
3055
+ worktree=$(hr_worktree_dir "$root" "$branch") || review_fail "resolving the working copy"
3056
+ had_copy=0
3057
+ [ ! -e "$worktree" ] || had_copy=1
3058
+ had_branch=0
3059
+ ! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
3060
+ trap start_remove_copy EXIT
3061
+ bash "$script_dir/create-worktree.sh" --existing --no-bootstrap "$branch" >&2 || status=$?
3062
+ [ "$status" -eq 0 ] || review_fail "the working copy (create-worktree.sh exited $status)"
3063
+ fi
3064
+ # Also for a cut copy: a local branch that existed before may be behind origin.
3065
+ git -C "$worktree" fetch origin "$branch" >&2 || review_fail "git fetch origin $branch in '$worktree'"
3066
+ git -C "$worktree" merge --ff-only "origin/$branch" >&2 || review_fail "git merge --ff-only origin/$branch in '$worktree'"
3067
+
3068
+ # The round, from the branch tip: the engine's own round source.
3069
+ state_rel=$(hr_state_dir "$worktree") || review_fail "resolving the state directory in '$worktree'"
3070
+ [ -n "$state_rel" ] || review_fail "resolving the state directory in '$worktree'"
3071
+ state_rel="${state_rel%/}"
3072
+ names=$(git -C "$worktree" ls-tree --name-only HEAD -- "$state_rel/user_reviews/") \
3073
+ || review_fail "listing $state_rel/user_reviews/ on $branch"
3074
+ while IFS= read -r name; do
3075
+ name="${name##*/}"
3076
+ [[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
3077
+ [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
3078
+ round="${BASH_REMATCH[3]:-1}"
3079
+ round=$((10#$round))
3080
+ [ "$round" -le "$max" ] || max="$round"
3081
+ done <<NAMES
3082
+ $names
3083
+ NAMES
3084
+ if [ "$max" -eq 0 ]; then
3085
+ round=1
3086
+ next="${branch}_review.md"
3087
+ else
3088
+ round=$((max + 1))
3089
+ next="${branch}_review_$round.md"
3090
+ fi
3091
+ rel="$state_rel/user_reviews/$next"
3092
+
3093
+ hr_place_artifact "$worktree" "$review_file" "$rel" || review_fail "copying the review to '$worktree/$rel'"
3094
+ # 3 (nothing staged) cannot happen for a new round's file, so it reads as a failure.
3095
+ status=0
3096
+ hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
3097
+ [ "$status" -eq 0 ] || review_fail "committing '$rel'"
3098
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3099
+ || review_fail "pushing $branch (origin/$branch is not HEAD)"
3100
+ pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
3101
+ if [ "$use_mirror" -eq 0 ]; then
3102
+ start_remove_copy
3103
+ trap - EXIT
3104
+ fi
3105
+
3106
+ echo "remote-run.sh: placed $rel (round $round) on $branch"
3107
+ engine=user_review
3108
+ resume=none
3109
+ chain=0
3110
+ dispatch_fail_note="; the review is already pushed to origin/$branch, so re-send with: remote-run.sh dispatch $branch --engine user_review"
3111
+ verb_dispatch
3112
+
3113
+ # Held until GitHub lists the dispatched run: the next job serialized behind
3114
+ # this one would otherwise read the branch as settled and place a second
3115
+ # round, whose dispatch cancels this one's pending run in the run workflow's
3116
+ # concurrency group.
3117
+ if run_by_sha_var "$pushed"; then
3118
+ echo "remote-run.sh: the dispatched run of $branch is listed: $RUN_BY_SHA_URL"
3119
+ else
3120
+ echo "::warning::remote-run.sh: no \`harness run $branch\` run of ${pushed:-the pushed commit} was listed after $TRIGGER_RUN_LOOKUP_TRIES lookups; the round is dispatched, but a review job reading $branch now may read it as settled"
3121
+ fi
3122
+
3123
+ if remote_record_exists "$reg"; then
3124
+ hr_registry_set "$reg" "$branch" status running engine user_review \
3125
+ || echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
3126
+ fi
3127
+
3128
+ if [ -n "$reviewers_arg" ]; then
3129
+ pr=""
3130
+ if [[ "$source_arg" =~ /pull/([0-9]+)([/?#]|$) ]]; then
3131
+ pr="${BASH_REMATCH[1]}"
3132
+ elif forge_on && forge_repo_var && forge_pr_var "$branch"; then
3133
+ pr="$FORGE_PR"
3134
+ fi
3135
+ note="Round $round from pull request${pr:+ #$pr}"
3136
+ [ -z "$source_arg" ] || note="$note ($source_arg)"
3137
+ note="$note by @${reviewers_arg//,/, @}"
3138
+ else
3139
+ note="Round $round"
3140
+ [ -z "$source_arg" ] || note="$note from $source_arg"
3141
+ if [ -n "$actor_arg" ]; then
3142
+ note="$note by @$actor_arg"
3143
+ else
3144
+ note="$note from a local session"
3145
+ fi
3146
+ fi
3147
+ forge_report round "$branch" "$note"
3148
+ }
3149
+
3150
+ # unrecorded_runs — UNRECORDED: one `<branch>\t<url>` line per branch whose
3151
+ # newest `harness run <branch>` run is on GitHub, newest first, dropping a
3152
+ # branch with a registry record, one `hr_branch_is_protected` does not answer 1
3153
+ # for, and one that is not a live head on origin. One listing and one
3154
+ # `ls-remote`; writes nothing. Exits 3 when either fails.
3155
+ UNRECORDED=""
3156
+ unrecorded_runs() {
3157
+ local titled reg recorded="" heads="" live=$'\n' ref b url protected
3158
+ UNRECORDED=""
3159
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || {
3160
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
3161
+ exit "$EXIT_USAGE"
3162
+ }
3163
+ list_all_runs || gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
3164
+ # Newest first, one line per branch: its newest `harness run <branch>` run.
3165
+ titled=$(printf '%s' "$ALL_RUNS" | jq -r '
3166
+ [.[] | select((.displayTitle // "") | startswith("harness run "))
3167
+ | . + {b: (.displayTitle | ltrimstr("harness run "))}]
3168
+ | sort_by([.createdAt, .databaseId]) | reverse
3169
+ | reduce .[] as $r ({seen: {}, out: []};
3170
+ if .seen[$r.b] then . else (.seen[$r.b] = true | .out += [$r]) end)
3171
+ | .out[] | "\(.b)\t\(.url // "")"' 2>/dev/null) || {
3172
+ GH_ERR="its run list is not the expected JSON"
3173
+ gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
3174
+ }
3175
+
3176
+ # Tested with -f first: hr_registry_get creates an absent registry.
3177
+ if [ -f "$reg" ]; then
3178
+ recorded=$(jq -r '.runs | keys[]' "$reg" 2>/dev/null) || {
3179
+ echo "remote-run.sh: cannot read the registry '$reg'" >&2
3180
+ exit "$EXIT_USAGE"
3181
+ }
3182
+ fi
3183
+ recorded=$'\n'"$recorded"$'\n'
3184
+
3185
+ if [ -n "$titled" ]; then
3186
+ heads=$(git -C "$root" ls-remote --heads origin 2>&1) || {
3187
+ echo "remote-run.sh: reading origin's branches failed: ${heads%%$'\n'*}" >&2
3188
+ exit "$EXIT_GH"
3189
+ }
3190
+ while IFS=$'\t' read -r _ ref; do
3191
+ case "$ref" in refs/heads/*) live="$live${ref#refs/heads/}"$'\n' ;; esac
3192
+ done <<EOF
3193
+ $heads
3194
+ EOF
3195
+ fi
3196
+
3197
+ while IFS=$'\t' read -r b url; do
3198
+ valid_branch "$b" || continue
3199
+ case "$recorded" in *$'\n'"$b"$'\n'*) continue ;; esac
3200
+ protected=0
3201
+ hr_branch_is_protected "$root" "$b" || protected=$?
3202
+ [ "$protected" -eq 1 ] || continue
3203
+ case "$live" in *$'\n'"$b"$'\n'*) ;; *) continue ;; esac
3204
+ UNRECORDED="$UNRECORDED$b"$'\t'"$url"$'\n'
3205
+ done <<EOF
3206
+ $titled
3207
+ EOF
3208
+ }
3209
+
3210
+ # ---------------------------------------------------------------------------
3211
+ # `list` — the runs on GitHub with no local record, for `branch-status`.
3212
+ # ---------------------------------------------------------------------------
3213
+
3214
+ verb_list() {
3215
+ local b url
3216
+ unrecorded_runs
3217
+ if [ -z "$UNRECORDED" ]; then
3218
+ echo "remote-run.sh: no run on GitHub without a local record"
3219
+ return 0
3220
+ fi
3221
+ while IFS=$'\t' read -r b url; do
3222
+ [ -n "$b" ] || continue
3223
+ echo "remote-run.sh: on GitHub, no local record: $b $url"
3224
+ done <<EOF
3225
+ $UNRECORDED
3226
+ EOF
3227
+ }
3228
+
3229
+ # ---------------------------------------------------------------------------
3230
+ # `trigger` — the event adapter. Event text is data: every field is read by
3231
+ # `jq` into a variable and reaches a command only as one argument or as file
3232
+ # bytes, never as shell source.
3233
+ # ---------------------------------------------------------------------------
3234
+
3235
+ trigger_tmp=""
3236
+ trigger_label=""
3237
+ issue_number=""
3238
+ # `issue` or `dispatch`: where trigger_finish reports.
3239
+ trigger_source=""
3240
+
3241
+ # event_field <jq filter> — one field of the event into EVENT_VALUE, its bytes
3242
+ # kept (a command substitution alone would drop trailing newlines). 1 when jq
3243
+ # cannot read it.
3244
+ EVENT_VALUE=""
3245
+ event_field() {
3246
+ local out
3247
+ out=$(jq -j "$1" "$GITHUB_EVENT_PATH" 2>/dev/null && printf x) || return 1
3248
+ EVENT_VALUE=${out%x}
3249
+ }
3250
+
3251
+ # trigger_finish <exit> <comment> <event> — post <comment> on the issue with the
3252
+ # marker for <event> (`started` or `refused`; `branch=` is empty before a branch
3253
+ # is derived), remove the trigger label, on `started` set the state label
3254
+ # `running`, and exit <exit>. A comment that cannot be posted makes the exit 3;
3255
+ # a label that cannot be removed or set is a warning only. For a dispatch event,
3256
+ # print <comment> with no marker and append it to GITHUB_STEP_SUMMARY when set;
3257
+ # a summary that cannot be appended is a warning only, since stdout already
3258
+ # carries it.
3259
+ trigger_finish() {
3260
+ local code="$1" body="$2" event="$3" file
3261
+ if [ "$trigger_source" = dispatch ]; then
3262
+ printf '%s\n' "$body"
3263
+ if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
3264
+ printf '%s\n' "### harness trigger" "" "$body" "" >>"$GITHUB_STEP_SUMMARY" \
3265
+ || echo "::warning::remote-run.sh: trigger: cannot append to GITHUB_STEP_SUMMARY"
3266
+ fi
3267
+ exit "$code"
3268
+ fi
3269
+ if [ -n "${GITHUB_RUN_ID-}" ]; then
3270
+ body="$body
3271
+
3272
+ _Posted by the trigger job ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/runs/$GITHUB_RUN_ID._"
3273
+ fi
3274
+ if ! file=$(mktemp "$trigger_tmp/harness-trigger-comment.XXXXXX"); then
3275
+ echo "::error::remote-run.sh: trigger: cannot create the comment file for issue #$issue_number under '$trigger_tmp'"
3276
+ exit "$EXIT_GH"
3277
+ fi
3278
+ { printf '%s\n\n' "$body"; forge_marker "$event" "$branch"; } >"$file"
3279
+ if ! gh_call issue comment "$issue_number" --repo "${GITHUB_REPOSITORY-}" --body-file "$file"; then
3280
+ echo "::error::remote-run.sh: trigger: the comment on issue #$issue_number could not be posted: $GH_ERR"
3281
+ code="$EXIT_GH"
3282
+ fi
3283
+ rm -f "$file"
3284
+ if ! gh_call issue edit "$issue_number" --repo "${GITHUB_REPOSITORY-}" --remove-label "$trigger_label"; then
3285
+ echo "::warning::remote-run.sh: trigger: removing the label '$trigger_label' from issue #$issue_number failed: $GH_ERR"
3286
+ fi
3287
+ if [ "$event" = started ]; then
3288
+ if ! { forge_repo_var && forge_set_state "$issue_number" running; }; then
3289
+ echo "::warning::remote-run.sh: trigger: setting the label '${STATE_LABEL_PREFIX}running' on issue #$issue_number failed: $GH_ERR"
3290
+ fi
3291
+ fi
3292
+ exit "$code"
3293
+ }
3294
+
3295
+ # trigger_refuse <reason> <way on> — print the reason, comment both, exit 2.
3296
+ trigger_refuse() {
3297
+ echo "remote-run.sh: trigger: refused, nothing sent: $1" >&2
3298
+ trigger_finish "$EXIT_REFUSED" "No run started: $1
3299
+
3300
+ $2" refused
3301
+ }
3302
+
3303
+ # trigger_bot_listed <login> — 0 when <login> is an exact entry of
3304
+ # HARNESS_TRIGGER_ALLOWED_BOTS, split on `,` with each entry trimmed.
3305
+ trigger_bot_listed() {
3306
+ local rest="${HARNESS_TRIGGER_ALLOWED_BOTS-}," entry
3307
+ while [ -n "$rest" ]; do
3308
+ entry=${rest%%,*}
3309
+ rest=${rest#*,}
3310
+ entry=${entry#"${entry%%[![:space:]]*}"}
3311
+ entry=${entry%"${entry##*[![:space:]]}"}
3312
+ [ -n "$entry" ] && [ "$entry" = "$1" ] && return 0
3313
+ done
3314
+ return 1
3315
+ }
3316
+
3317
+ # authorise_actor <login> <type> — the one actor check, shared by `trigger` and
3318
+ # `control`: 0 when authorised. Otherwise AUTH_WHY holds one sentence and the
3319
+ # status names the arm: 1 `ghost`, empty or not a login shape; 2 a non-`User`
3320
+ # not listed in HARNESS_TRIGGER_ALLOWED_BOTS, decided with no permission call,
3321
+ # because the permission API answers `none` or 404 for a bot; 3 a `User` whose
3322
+ # permission is not `admin` or `write` (AUTH_PERMISSION holds it); 4 that
3323
+ # permission call failed (GH_ERR holds why). Prints nothing and never posts.
3324
+ AUTH_WHY=""
3325
+ AUTH_PERMISSION=""
3326
+ authorise_actor() {
3327
+ local login="$1" type="$2"
3328
+ AUTH_WHY=""
3329
+ AUTH_PERMISSION=""
3330
+ if [ "$login" = ghost ] || [ -z "$login" ] \
3331
+ || ! { [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \
3332
+ || { [ "$type" = Bot ] && [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$ ]]; }; }; then
3333
+ AUTH_WHY="the actor is an account GitHub does not name, or not a login."
3334
+ return 1
3335
+ fi
3336
+ if [ "$type" != User ]; then
3337
+ trigger_bot_listed "$login" && return 0
3338
+ AUTH_WHY="@$login is not a person, and is not listed in HARNESS_TRIGGER_ALLOWED_BOTS."
3339
+ return 2
3340
+ fi
3341
+ if ! gh_call api "repos/${GITHUB_REPOSITORY-}/collaborators/$login/permission"; then
3342
+ AUTH_WHY="the permission check for @$login failed ($GH_ERR)."
3343
+ return 4
3344
+ fi
3345
+ AUTH_PERMISSION=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || AUTH_PERMISSION=""
3346
+ case "$AUTH_PERMISSION" in
3347
+ admin|write) return 0 ;;
3348
+ esac
3349
+ AUTH_WHY="GitHub reports the permission of @$login as ${AUTH_PERMISSION:-nothing}, not write or admin."
3350
+ return 3
3351
+ }
3352
+
3353
+ # run_by_sha_var <sha> — RUN_BY_SHA_URL: the URL of the `harness run
3354
+ # <branch>` run whose `headSha` is <sha>, a commit just pushed and dispatched,
3355
+ # looked up at most TRIGGER_RUN_LOOKUP_TRIES times, `HARNESS_TRIGGER_LOOKUP_SECS`
3356
+ # apart; 1 when none was listed within that bound, and at once when <sha> is
3357
+ # empty. Never exits. Matched on `headSha` rather than a `createdAt` bound: the
3358
+ # SHA identifies this dispatch exactly and reads no runner clock, where a time
3359
+ # bound still admits an unrelated run created in the same second.
3360
+ RUN_BY_SHA_URL=""
3361
+ run_by_sha_var() {
3362
+ local sha="${1-}" try=1 secs url=""
3363
+ RUN_BY_SHA_URL=""
3364
+ secs="${HARNESS_TRIGGER_LOOKUP_SECS-}"
3365
+ case "$secs" in
3366
+ ''|*[!0-9]*) secs="$TRIGGER_LOOKUP_SECS_DEFAULT" ;;
3367
+ esac
3368
+ while [ -n "$sha" ]; do
3369
+ if gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json url,displayTitle,headSha --limit 5; then
3370
+ url=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg s "$sha" \
3371
+ '[.[]? | select(.displayTitle == $t and .headSha == $s) | .url | strings] | first // empty' 2>/dev/null) || url=""
3372
+ [ -z "$url" ] || break
3373
+ else
3374
+ echo "remote-run.sh: $verb: looking up the run of $branch failed: $GH_ERR" >&2
3375
+ fi
3376
+ [ "$try" -lt "$TRIGGER_RUN_LOOKUP_TRIES" ] || break
3377
+ try=$((try + 1))
3378
+ sleep "$secs"
3379
+ done
3380
+ RUN_BY_SHA_URL="$url"
3381
+ [ -n "$url" ]
3382
+ }
3383
+
3384
+ # trigger_run_url <sha> — `run_by_sha_var`'s URL for the commit `start` just
3385
+ # pushed; the branch's filtered run list when none appears. Never fails.
3386
+ trigger_run_url() {
3387
+ local url=""
3388
+ ! run_by_sha_var "${1-}" || url="$RUN_BY_SHA_URL"
3389
+ # A derived name is `[a-z0-9_]` only, so it needs no encoding in the query.
3390
+ [ -n "$url" ] || url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/workflows/${WORKFLOW_RUN_FILE}?query=branch%3A$branch"
3391
+ printf '%s\n' "$url"
3392
+ }
3393
+
3394
+ verb_trigger() {
3395
+ local LC_ALL=C
3396
+ local action label title body html_url state login sender_type source=""
3397
+ local forge="" target="" default name_file status prompt errfile last url sha
3398
+ local fallback retry_then retry_again task_what
3399
+ case "${GITHUB_EVENT_NAME-}" in
3400
+ issues) trigger_source=issue ;;
3401
+ repository_dispatch) trigger_source=dispatch ;;
3402
+ *)
3403
+ echo "remote-run.sh: trigger handles GITHUB_EVENT_NAME issues or repository_dispatch, not '${GITHUB_EVENT_NAME-}'" >&2
3404
+ exit "$EXIT_USAGE" ;;
3405
+ esac
3406
+ if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
3407
+ echo "remote-run.sh: trigger: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
3408
+ exit "$EXIT_USAGE"
3409
+ fi
3410
+ hr_have_jq || { echo "remote-run.sh: trigger needs jq" >&2; exit "$EXIT_USAGE"; }
3411
+
3412
+ if [ "$trigger_source" = issue ]; then
3413
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
3414
+ && event_field '.label.name // ""' && label="$EVENT_VALUE" \
3415
+ && event_field '.issue.number // ""' && issue_number="$EVENT_VALUE" \
3416
+ && event_field '.issue.title // ""' && title="$EVENT_VALUE" \
3417
+ && event_field '.issue.body // ""' && body="$EVENT_VALUE" \
3418
+ && event_field '.issue.html_url // ""' && html_url="$EVENT_VALUE" \
3419
+ && event_field '.issue.state // ""' && state="$EVENT_VALUE" \
3420
+ && event_field '.sender.login // ""' && login="$EVENT_VALUE" \
3421
+ && event_field '.sender.type // ""' && sender_type="$EVENT_VALUE"; } || {
3422
+ echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
3423
+ exit "$EXIT_USAGE"
3424
+ }
3425
+ trigger_label="${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}"
3426
+ if [ -z "${HARNESS_TRIGGER_LABEL-}" ] && [ "$label" = "$LEGACY_TRIGGER_LABEL" ]; then
3427
+ trigger_label="$LEGACY_TRIGGER_LABEL"
3428
+ fi
3429
+ if [ "$action" != labeled ] || [ "$label" != "$trigger_label" ]; then
3430
+ echo "remote-run.sh: trigger: ignored, not the label '$trigger_label' being applied"
3431
+ return 0
3432
+ fi
3433
+ case "$issue_number" in
3434
+ ''|*[!0-9]*|0*)
3435
+ echo "remote-run.sh: trigger: the event carries no issue number" >&2
3436
+ exit "$EXIT_USAGE" ;;
3437
+ esac
3438
+ fallback="issue_$issue_number"
3439
+ retry_then="re-apply the label \`$trigger_label\`"
3440
+ retry_again="Re-apply the label \`$trigger_label\` to try again."
3441
+ task_what="this issue's task"
3442
+ else
3443
+ # GitHub puts a repository_dispatch's `event_type` in `.action`.
3444
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
3445
+ && event_field '.client_payload.title // ""' && title="$EVENT_VALUE" \
3446
+ && event_field '.client_payload.body // ""' && body="$EVENT_VALUE" \
3447
+ && event_field '.client_payload.source // ""' && source="$EVENT_VALUE"; } || {
3448
+ echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
3449
+ exit "$EXIT_USAGE"
3450
+ }
3451
+ if [ "$action" != "$TRIGGER_DISPATCH_EVENT_TYPE" ]; then
3452
+ echo "remote-run.sh: trigger: ignored, a repository_dispatch of type '$action', not '$TRIGGER_DISPATCH_EVENT_TYPE'"
3453
+ return 0
3454
+ fi
3455
+ fallback="task_${GITHUB_RUN_ID-}"
3456
+ retry_then="send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again"
3457
+ retry_again="Send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again to try again."
3458
+ task_what="the dispatched task"
3459
+ fi
3460
+
3461
+ trigger_tmp="${RUNNER_TEMP-}"
3462
+ if [ -z "$trigger_tmp" ] || [ ! -d "$trigger_tmp" ]; then
3463
+ trigger_tmp=$(mktemp -d) || { echo "remote-run.sh: trigger: mktemp failed" >&2; exit "$EXIT_USAGE"; }
3464
+ fi
3465
+
3466
+ if [ "$trigger_source" = dispatch ] && [ -z "$title" ]; then
3467
+ trigger_refuse "the dispatch's \`client_payload\` carries no \`title\`." \
3468
+ "Send it as \`{\"event_type\": \"$TRIGGER_DISPATCH_EVENT_TYPE\", \"client_payload\": {\"title\": …, \"body\": …, \"source\": …}}\`, with a non-empty \`title\`."
3469
+ fi
3470
+
3471
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
3472
+ trigger_refuse "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every start." \
3473
+ "Clear it under **Settings → Secrets and variables → Actions → Variables**, then $retry_then."
3474
+ fi
3475
+
3476
+ forge=$(hr_forge "$root") || forge=""
3477
+ target=$(hr_execution_target "$root") || target=""
3478
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
3479
+ trigger_refuse "the default branch's \`harness.config.json\` does not turn the $trigger_source trigger on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})." \
3480
+ "Set both keys on the default branch, then $retry_then."
3481
+ fi
3482
+
3483
+ if [ "$trigger_source" = issue ]; then
3484
+ if [ "$state" != open ]; then
3485
+ trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
3486
+ fi
3487
+
3488
+ status=0
3489
+ authorise_actor "$login" "$sender_type" || status=$?
3490
+ case "$status" in
3491
+ 0) ;;
3492
+ 1) trigger_refuse "the label was applied by an account GitHub does not name (a deleted account shows as \`ghost\`)." \
3493
+ "A collaborator with write access can re-apply the label \`$trigger_label\`." ;;
3494
+ 2) trigger_refuse "@$login is not a person, and is not listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`." \
3495
+ "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`." ;;
3496
+ 3) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${AUTH_PERMISSION:-nothing}\`." \
3497
+ "Only a collaborator with write, maintain or admin access starts a run by labelling an issue; one of them can re-apply the label \`$trigger_label\`." ;;
3498
+ *) trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
3499
+ "Re-apply the label \`$trigger_label\` to try again." ;;
3500
+ esac
3501
+ fi
3502
+
3503
+ # The name check reads origin/<defaultBranch>; a failed fetch leaves it to say so.
3504
+ default=$(hr_default_branch "$root") || default=""
3505
+ if [ -n "$default" ]; then
3506
+ git -C "$root" fetch --quiet origin "$default" >&2 || echo "remote-run.sh: trigger: fetching origin $default failed" >&2
3507
+ fi
3508
+ name_file=$(mktemp "$trigger_tmp/harness-trigger-branch.XXXXXX") || trigger_refuse \
3509
+ "the branch name could not be derived (mktemp failed)." "$retry_again"
3510
+ status=0
3511
+ hr_derive_branch "$root" "$title" "$fallback" "" "$GH" >"$name_file" || status=$?
3512
+ branch=""
3513
+ IFS= read -r branch <"$name_file" || :
3514
+ rm -f "$name_file"
3515
+ case "$status" in
3516
+ 0) ;;
3517
+ 3) if [ "$trigger_source" = issue ]; then
3518
+ trigger_refuse "every branch name derived from this issue's title, through the suffix \`_99\`, is already taken." \
3519
+ "Retitle the issue, then re-apply the label \`$trigger_label\`."
3520
+ fi
3521
+ trigger_refuse "every branch name derived from the dispatch's \`title\`, through the suffix \`_99\`, is already taken." \
3522
+ "Send the dispatch again with another \`title\`." ;;
3523
+ *) trigger_refuse "the branch name for $task_what could not be checked (${HR_TAKEN_WHY:-no usable name})." \
3524
+ "$retry_again" ;;
3525
+ esac
3526
+
3527
+ prompt=$(mktemp "$trigger_tmp/harness-trigger-prompt.XXXXXX") || trigger_finish "$EXIT_PLACEMENT" \
3528
+ "No run started: the task prompt for \`$branch\` could not be written. $retry_again" refused
3529
+ if [ "$trigger_source" = issue ]; then
3530
+ printf '# %s\n\n%s\n\n---\n\nStarted from %s by @%s, who applied the label `%s` at %s. This is the issue'"'"'s text at that moment; later edits to the issue do not reach this run.\n' \
3531
+ "$title" "$body" "$html_url" "$login" "$trigger_label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
3532
+ else
3533
+ [ -z "$source" ] || source=", from $source"
3534
+ printf '# %s\n\n%s\n\n---\n\nStarted by a repository_dispatch event of type `%s`%s at %s.\n' \
3535
+ "$title" "$body" "$TRIGGER_DISPATCH_EVENT_TYPE" "$source" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
3536
+ fi
3537
+
3538
+ errfile=$(mktemp "$trigger_tmp/harness-trigger-start.XXXXXX") || errfile=/dev/null
3539
+ status=0
3540
+ bash "$script_dir/remote-run.sh" start "$branch" --prompt-file "$prompt" --repo "$root" 2>"$errfile" || status=$?
3541
+ last=""
3542
+ if [ "$errfile" != /dev/null ]; then
3543
+ cat "$errfile" >&2
3544
+ last=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
3545
+ rm -f "$errfile"
3546
+ fi
3547
+ case "$status" in
3548
+ 0) ;;
3549
+ 3)
3550
+ echo "remote-run.sh: trigger: $branch is pushed, but its dispatch failed" >&2
3551
+ trigger_finish "$EXIT_GH" "The branch \`$branch\` was pushed with $task_what, but dispatching its run failed:
3552
+
3553
+ \`\`\`
3554
+ $last
3555
+ \`\`\`
3556
+
3557
+ Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with *Use workflow from* set to \`$branch\`, \`action\` \`run\` and \`branch\` \`$branch\`." refused ;;
3558
+ *)
3559
+ echo "remote-run.sh: trigger: the start of $branch failed (exit $status)" >&2
3560
+ trigger_finish "$EXIT_PLACEMENT" "No run started: placing $task_what on the branch \`$branch\` failed:
3561
+
3562
+ \`\`\`
3563
+ $last
3564
+ \`\`\`
3565
+
3566
+ $retry_again" refused ;;
3567
+ esac
3568
+
3569
+ # The ref `start`'s `hr_push_landed` confirmed equal to the pushed `HEAD`;
3570
+ # `start_remove_copy` deletes only the local branch.
3571
+ sha=$(git -C "$root" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}") || sha=""
3572
+ url=$(trigger_run_url "$sha")
3573
+ if [ "$trigger_source" = issue ]; then
3574
+ echo "remote-run.sh: trigger: started $branch from issue #$issue_number: $url"
3575
+ trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
3576
+
3577
+ The task is this issue's title and body as they were when the label \`$trigger_label\` was applied; later edits to the issue do not reach this run. Re-applying the label starts another run, on the next indexed branch." started
3578
+ fi
3579
+ echo "remote-run.sh: trigger: started $branch from a repository_dispatch: $url"
3580
+ trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
3581
+
3582
+ The task is the dispatch's \`client_payload\` title and body. Sending the same dispatch again starts another run, on the next indexed branch." started
3583
+ }
3584
+
3585
+ # ---------------------------------------------------------------------------
3586
+ # THE FORGE SURFACE — the one place this script reads or writes an issue or a
3587
+ # pull request for a run. Every function sets globals rather than printing,
3588
+ # never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
3589
+ #
3590
+ # THE TARGET RULE. A comment goes to the open same-repository pull request
3591
+ # whose head is the branch when `forge_recognised` holds for that branch, else
3592
+ # to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
3593
+ # state label goes on that issue and on that pull request, each when known.
3594
+ #
3595
+ # THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
3596
+ # (`remote_state`); the harness overwrites any state label set by hand, and
3597
+ # nothing reads one back to decide anything.
3598
+ # ---------------------------------------------------------------------------
3599
+
3600
+ # forge_on — 0 when `forge` is `github` and `execution.target` is
3601
+ # `github-actions`; the shell mirror of `forgeTriggerApplies`.
3602
+ forge_on() {
3603
+ [ "$(hr_forge "$root" 2>/dev/null)" = github ] \
3604
+ && [ "$(hr_execution_target "$root" 2>/dev/null)" = github-actions ]
3605
+ }
3606
+
3607
+ # forge_repo_var — FORGE_REPO (owner/name) and FORGE_SERVER, from the runner's
3608
+ # environment when it names the repository, else one `gh repo view`; a success
3609
+ # is kept for the invocation.
3610
+ FORGE_REPO=""
3611
+ FORGE_SERVER=""
3612
+ FORGE_REPO_KNOWN=0
3613
+ forge_repo_var() {
3614
+ local repo
3615
+ [ "$FORGE_REPO_KNOWN" -eq 0 ] || return 0
3616
+ if [ -n "${GITHUB_REPOSITORY-}" ]; then
3617
+ repo="$GITHUB_REPOSITORY"
3618
+ else
3619
+ if ! gh_call repo view --json nameWithOwner; then
3620
+ echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
3621
+ return 1
3622
+ fi
3623
+ repo=$(printf '%s' "$GH_OUT" | jq -r '.nameWithOwner // empty' 2>/dev/null) || repo=""
3624
+ fi
3625
+ # Interpolated into every API path below, so its shape is checked once here.
3626
+ if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
3627
+ GH_ERR="the repository name '$repo' is not owner/name"
3628
+ echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
3629
+ return 1
3630
+ fi
3631
+ FORGE_REPO="$repo"
3632
+ FORGE_SERVER="${GITHUB_SERVER_URL:-https://github.com}"
3633
+ FORGE_SERVER="${FORGE_SERVER%/}"
3634
+ FORGE_REPO_KNOWN=1
3635
+ }
3636
+
3637
+ # forge_fetch_branch <branch> — update refs/remotes/origin/<branch>, at most
3638
+ # once per branch per invocation. The refspec is explicit because a single-branch
3639
+ # checkout's bare `fetch origin <branch>` updates only FETCH_HEAD. A failure is
3640
+ # one line and tolerated.
3641
+ FORGE_FETCHED=" "
3642
+ forge_fetch_branch() {
3643
+ local err
3644
+ case "$FORGE_FETCHED" in *" $1 "*) return 0 ;; esac
3645
+ FORGE_FETCHED="$FORGE_FETCHED$1 "
3646
+ if ! err=$(git -C "$root" fetch --quiet origin "+refs/heads/$1:refs/remotes/origin/$1" 2>&1 >/dev/null); then
3647
+ echo "remote-run.sh: fetching origin $1 failed: ${err%%$'\n'*}" >&2
3648
+ fi
3649
+ return 0
3650
+ }
3651
+
3652
+ # forge_marker <event> <branch> [<question>] — the one producer of a comment's
3653
+ # marker line, built from COMMENT_MARKER.
3654
+ forge_marker() {
3655
+ if [ -n "${3-}" ]; then
3656
+ printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
3657
+ else
3658
+ printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
3659
+ fi
3660
+ }
3661
+
3662
+ # forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
3663
+ # `verb_trigger` writes into the branch's committed task prompt, matched against
3664
+ # this repository's own issue URL only; empty when there is none. Also sets
3665
+ # FORGE_TRIGGER_LABEL, the label that same provenance line names; empty when
3666
+ # it names none.
3667
+ FORGE_ISSUE=""
3668
+ FORGE_TRIGGER_LABEL=""
3669
+ forge_issue_var() {
3670
+ local state_rel rel prompt line rest num prefix label
3671
+ FORGE_ISSUE=""
3672
+ FORGE_TRIGGER_LABEL=""
3673
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3674
+ if [ -z "$state_rel" ]; then
3675
+ echo "remote-run.sh: cannot resolve the state directory under '$root'" >&2
3676
+ return 1
3677
+ fi
3678
+ rel=$(hr_task_prompt_rel "$state_rel" "$1")
3679
+ prompt=$(git -C "$root" show "refs/remotes/origin/$1:$rel" 2>/dev/null) || return 0
3680
+ prefix="Started from $FORGE_SERVER/$FORGE_REPO/issues/"
3681
+ while IFS= read -r line; do
3682
+ case "$line" in
3683
+ "$prefix"*) ;;
3684
+ *) continue ;;
3685
+ esac
3686
+ rest=${line#"$prefix"}
3687
+ num=${rest%%[!0-9]*}
3688
+ [ -n "$num" ] || continue
3689
+ case "${rest#"$num"}" in
3690
+ ' by @'*)
3691
+ FORGE_ISSUE="$num"
3692
+ FORGE_TRIGGER_LABEL=""
3693
+ case "$line" in
3694
+ *'who applied the label `'*'`'*)
3695
+ label=${line#*'who applied the label `'}
3696
+ label=${label%%'`'*}
3697
+ [ -z "$label" ] || FORGE_TRIGGER_LABEL="$label" ;;
3698
+ esac ;;
3699
+ esac
3700
+ done <<<"$prompt"
3701
+ return 0
3702
+ }
3703
+
3704
+ # forge_recognised <branch> — the harness-branch test, read from committed
3705
+ # state: 0 when origin's copy of the branch carries its flow-progress ledger.
3706
+ forge_recognised() {
3707
+ local state_rel
3708
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
3709
+ [ -n "$state_rel" ] || return 1
3710
+ git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null
3711
+ }
3712
+
3713
+ # forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
3714
+ # <branch> in this repository (a fork's same-named head is skipped), or empty.
3715
+ FORGE_PR=""
3716
+ forge_pr_var() {
3717
+ FORGE_PR=""
3718
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository --limit 10; then
3719
+ echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3720
+ return 1
3721
+ fi
3722
+ if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
3723
+ 'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
3724
+ FORGE_PR=""
3725
+ GH_ERR="its pr list is not the expected JSON"
3726
+ echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3727
+ return 1
3728
+ fi
3729
+ return 0
3730
+ }
3731
+
3732
+ # forge_comment <number> <event> <branch> <body_file> [<question>] — append the
3733
+ # marker to <body_file> and post it on issue or pull request <number>.
3734
+ forge_comment() {
3735
+ local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
3736
+ if ! { printf '\n'; forge_marker "$event" "$branch" "$question"; } >>"$file"; then
3737
+ echo "remote-run.sh: cannot append the marker to '$file'" >&2
3738
+ return 1
3739
+ fi
3740
+ gh_call api --method POST "repos/$FORGE_REPO/issues/$number/comments" -F "body=@$file"
3741
+ status=$?
3742
+ [ "$status" -eq 0 ] || echo "remote-run.sh: the $event comment on #$number could not be posted: $GH_ERR" >&2
3743
+ return "$status"
3744
+ }
3745
+
3746
+ # forge_set_state <number> <state> — leave `STATE_LABEL_PREFIX<state>` as the
3747
+ # one state label on <number>. A failed add creates the label and retries once,
3748
+ # never more.
3749
+ forge_set_state() {
3750
+ local number="$1" state="$2" label names name encoded color try
3751
+ case " $RUN_STATES " in
3752
+ *" $state "*) ;;
3753
+ *) echo "remote-run.sh: '$state' is not one of: $RUN_STATES" >&2; return 1 ;;
3754
+ esac
3755
+ label="$STATE_LABEL_PREFIX$state"
3756
+ if ! gh_call api "repos/$FORGE_REPO/issues/$number/labels"; then
3757
+ echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
3758
+ return 1
3759
+ fi
3760
+ if ! names=$(printf '%s' "$GH_OUT" | jq -r --arg p "$STATE_LABEL_PREFIX" --arg t "$label" \
3761
+ 'if type == "array" then .[] | .name | strings | select(startswith($p) and . != $t) else error end' 2>/dev/null); then
3762
+ GH_ERR="its label list is not the expected JSON"
3763
+ echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
3764
+ return 1
3765
+ fi
3766
+ while IFS= read -r name; do
3767
+ [ -n "$name" ] || continue
3768
+ encoded=$(jq -rn --arg s "$name" '$s|@uri')
3769
+ gh_call api --method DELETE "repos/$FORGE_REPO/issues/$number/labels/$encoded" \
3770
+ || echo "remote-run.sh: removing the label '$name' from #$number failed: $GH_ERR" >&2
3771
+ done <<<"$names"
3772
+ for try in 1 2; do
3773
+ gh_call api --method POST "repos/$FORGE_REPO/issues/$number/labels" -f "labels[]=$label" && return 0
3774
+ if [ "$try" -eq 2 ]; then
3775
+ echo "remote-run.sh: adding the label '$label' to #$number failed: $GH_ERR" >&2
3776
+ return 1
3777
+ fi
3778
+ case "$state" in
3779
+ running) color=1d76db ;;
3780
+ parked) color=fbca04 ;;
3781
+ paused) color=c5def5 ;;
3782
+ done) color=0e8a16 ;;
3783
+ failed) color=b60205 ;;
3784
+ *) color=6a737d ;;
3785
+ esac
3786
+ gh_call api --method POST "repos/$FORGE_REPO/labels" -f "name=$label" -f "color=$color" \
3787
+ -f "description=Set by the harness from its run list; a hand-applied state label is overwritten." \
3788
+ || echo "remote-run.sh: creating the label '$label' failed: $GH_ERR" >&2
3789
+ done
3790
+ }
3791
+
3792
+ # forge_utc <epoch> — print <epoch> as a UTC time, or nothing. `date -r` takes an
3793
+ # epoch on BSD and a reference file on GNU, hence the `-d @` fallback.
3794
+ forge_utc() {
3795
+ local out
3796
+ case "${1-}" in ''|*[!0-9]*) return 0 ;; esac
3797
+ out=$(date -u -r "$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
3798
+ [ -n "$out" ] || out=$(date -u -d "@$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
3799
+ printf '%s' "$out"
3800
+ }
3801
+
3802
+ # forge_question_body <out_file> <branch> <n> <open_count> <clar_dir> [<note>] —
3803
+ # write question <n>'s park comment, without its marker, into <out_file>: the
3804
+ # file's bytes, cut at the last whole line within QUESTION_COMMENT_MAX_BYTES
3805
+ # when it is over it (measured in bytes; `${#…}` counts characters).
3806
+ forge_question_body() {
3807
+ local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last
3808
+ size=$(wc -c <"$qfile") || return 1
3809
+ size=$((size))
3810
+ {
3811
+ printf 'The run on `%s` is waiting for an answer to question %s.\n\n' "$br" "$n"
3812
+ if [ "$size" -le "$QUESTION_COMMENT_MAX_BYTES" ]; then
3813
+ cat "$qfile"
3814
+ else
3815
+ cut=1
3816
+ head -c "$QUESTION_COMMENT_MAX_BYTES" "$qfile" >"$out.cut"
3817
+ last=$(tail -c 1 "$out.cut")
3818
+ # A non-empty last byte is a partial line; LC_ALL=C keeps sed from
3819
+ # refusing a multi-byte character the byte cut split.
3820
+ if [ -n "$last" ]; then LC_ALL=C sed '$d' "$out.cut"; else cat "$out.cut"; fi
3821
+ fi
3822
+ } >"$out" || return 1
3823
+ {
3824
+ [ -z "$(tail -c 1 "$out")" ] || printf '\n'
3825
+ [ "$cut" -eq 0 ] || printf '\nThis question was cut to fit a comment. The whole file is `%s/%s/question_%s.md` in the run'"'"'s `%s` artifact.\n' \
3826
+ "$HR_REMOTE_CLARIFY_DIR" "$br" "$n" "$STATE_ARTIFACT_NAME"
3827
+ printf '\nAnswer with a comment whose first line is `%s answer %s` and whose following lines are your answer.' "$COMMAND_HANDLE" "$n"
3828
+ [ "$count" -ne 1 ] || printf ' This is the only open question, so `%s` may be left out: `%s answer`.' "$n" "$COMMAND_HANDLE"
3829
+ printf '\n'
3830
+ [ -z "$note" ] || printf '\n%s\n' "$note"
3831
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3832
+ } >>"$out"
3833
+ }
3834
+
3835
+ # forge_report <event> <branch> [<note>] — one lifecycle comment (on `parked`,
3836
+ # one per open question) and the state label, by the target rule above.
3837
+ # Always 0.
3838
+ forge_report() {
3839
+ local event="$1" br="$2" note="${3-}" state reason="" resume_at="" when registry_file
3840
+ local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
3841
+ case "$event" in
3842
+ parked|park_loop) state=parked ;;
3843
+ paused) state=paused ;;
3844
+ resumed) state=running ;;
3845
+ failed) state=failed ;;
3846
+ stopped) state=stopped ;;
3847
+ round) state=running ;;
3848
+ completed)
3849
+ echo "remote-run.sh: report: completed is posted by deliver; nothing posted"
3850
+ return 0 ;;
3851
+ launched)
3852
+ echo "remote-run.sh: report: launched is the trigger's own comment; nothing posted"
3853
+ return 0 ;;
3854
+ *)
3855
+ echo "remote-run.sh: report: '$event' is not a reported event; nothing posted"
3856
+ return 0 ;;
3857
+ esac
3858
+ if ! forge_on; then
3859
+ echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
3860
+ return 0
3861
+ fi
3862
+ forge_repo_var || return 0
3863
+
3864
+ if [ "$event" = failed ]; then
3865
+ remote_branch_stopped "$br"
3866
+ stopped=$?
3867
+ if [ "$stopped" -eq 0 ]; then
3868
+ echo "remote-run.sh: report: $br was stopped, and the stop already reported the run; nothing posted"
3869
+ return 0
3870
+ fi
3871
+ [ "$stopped" -eq 1 ] \
3872
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
3873
+ fi
3874
+
3875
+ forge_fetch_branch "$br"
3876
+ forge_issue_var "$br" || FORGE_ISSUE=""
3877
+ forge_pr_var "$br" || FORGE_PR=""
3878
+ if [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
3879
+ echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
3880
+ FORGE_PR=""
3881
+ fi
3882
+ if [ -n "$FORGE_PR" ]; then
3883
+ target="$FORGE_PR"; kind=pr
3884
+ elif [ -n "$FORGE_ISSUE" ]; then
3885
+ target="$FORGE_ISSUE"; kind=issue
3886
+ else
3887
+ echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
3888
+ return 0
3889
+ fi
3890
+
3891
+ # Tested with -f first: hr_registry_get creates an absent registry.
3892
+ registry_file=$(hr_state_path "$root" autonomous_logs/registry.json 2>/dev/null) || registry_file=""
3893
+ if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
3894
+ reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
3895
+ resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
3896
+ fi
3897
+
3898
+ case "$event" in
3899
+ paused)
3900
+ if [ "$reason" = usage ]; then
3901
+ when=$(forge_utc "$resume_at")
3902
+ text="The harness run on \`$br\` paused: it reached its usage limit. It resumes by itself after the limit resets${when:+, at $when}."
3903
+ else
3904
+ text="The harness run on \`$br\` paused${reason:+ (reason: \`$reason\`)}. Comment \`${COMMAND_HANDLE} resume\` to continue."
3905
+ fi ;;
3906
+ park_loop)
3907
+ text="The harness run on \`$br\` is on hold: it parked on its questions again and again without progress. Comment \`${COMMAND_HANDLE} clear\` to clear the hold and let it continue." ;;
3908
+ parked)
3909
+ text="The harness run on \`$br\` is waiting for an answer. Its questions are in the run's \`$STATE_ARTIFACT_NAME\` artifact." ;;
3910
+ resumed)
3911
+ text="The harness run on \`$br\` resumed." ;;
3912
+ failed)
3913
+ if [ "$kind" = pr ]; then
3914
+ 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."
3915
+ else
3916
+ trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
3917
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, re-apply the label \`$trigger_label\` to this issue; that starts a new run, on the next indexed branch."
3918
+ fi ;;
3919
+ stopped)
3920
+ text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes." ;;
3921
+ round)
3922
+ text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
3923
+ esac
3924
+
3925
+ OPEN_QUESTIONS=""
3926
+ if [ "$event" = parked ]; then
3927
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3928
+ [ -z "$state_rel" ] || open_questions_in "$root/${state_rel%/}"
3929
+ fi
3930
+
3931
+ tmp="${RUNNER_TEMP-}"
3932
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
3933
+ tmp=$(mktemp -d) || tmp=""
3934
+ made_tmp="$tmp"
3935
+ fi
3936
+ if [ -n "$OPEN_QUESTIONS" ]; then
3937
+ count=$(printf '%s\n' $OPEN_QUESTIONS | wc -l)
3938
+ count=$((count))
3939
+ for n in $OPEN_QUESTIONS; do
3940
+ if [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
3941
+ if forge_question_body "$file" "$br" "$n" "$count" "$root/${state_rel%/}/$HR_REMOTE_CLARIFY_DIR/$br" "$note"; then
3942
+ forge_comment "$target" "$event" "$br" "$file" "$n" || :
3943
+ else
3944
+ echo "remote-run.sh: report: cannot write question $n's comment for #$target; not posted" >&2
3945
+ fi
3946
+ rm -f "$file" "$file.cut"
3947
+ else
3948
+ echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
3949
+ fi
3950
+ done
3951
+ elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
3952
+ {
3953
+ printf '%s\n' "$text"
3954
+ [ -z "$note" ] || printf '\n%s\n' "$note"
3955
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3956
+ } >"$file"
3957
+ forge_comment "$target" "$event" "$br" "$file" || :
3958
+ rm -f "$file"
3959
+ else
3960
+ echo "remote-run.sh: report: cannot create the comment file for #$target; no comment posted" >&2
3961
+ fi
3962
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
3963
+
3964
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
3965
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
3966
+ echo "remote-run.sh: report: $event on $br reported on #$target"
3967
+ return 0
3968
+ }
3969
+
3970
+ verb_report() {
3971
+ forge_report "$report_event" "$branch" "$report_note"
3972
+ exit "$EXIT_OK"
3973
+ }
3974
+
3975
+ # GitHub's refusal of a pull request created with the job's own token while the
3976
+ # repository's Actions setting is off (docs/github-integration-research.md -> C3).
3977
+ PR_CREATE_FORBIDDEN='GitHub Actions is not permitted to create or approve pull requests'
3978
+ # GitHub's limit on a pull request title.
3979
+ PR_TITLE_MAX_CHARS=256
3980
+
3981
+ # deliver_title_var <branch> — DELIVER_TITLE: the committed task prompt's first
3982
+ # line without its `# ` when it is a heading, cut to PR_TITLE_MAX_CHARS, else <branch>.
3983
+ DELIVER_TITLE=""
3984
+ deliver_title_var() {
3985
+ local state_rel first=""
3986
+ DELIVER_TITLE="$1"
3987
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 0
3988
+ [ -n "$state_rel" ] || return 0
3989
+ IFS= read -r first < <(git -C "$root" show "refs/remotes/origin/$1:$(hr_task_prompt_rel "$state_rel" "$1")" 2>/dev/null) || :
3990
+ case "$first" in
3991
+ '# '?*) DELIVER_TITLE="${first#'# '}"; DELIVER_TITLE="${DELIVER_TITLE:0:$PR_TITLE_MAX_CHARS}" ;;
3992
+ esac
3993
+ return 0
3994
+ }
3995
+
3996
+ # deliver_pr_body <file> <branch> — the pull request's body: what it is, the
3997
+ # issue as a plain mention (never a closing keyword: the flow does not own the
3998
+ # issue's lifecycle), what a reviewer can do here, and the marker.
3999
+ deliver_pr_body() {
4000
+ local v verbs=""
4001
+ for v in $COMMAND_VERBS; do
4002
+ [ "$v" != answer ] || v="answer <n>"
4003
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
4004
+ done
4005
+ {
4006
+ printf 'This pull request carries the harness run on `%s`, ready for your review. The harness never merges it.\n' "$2"
4007
+ [ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
4008
+ printf '\nA review that requests changes starts a user-review round on this branch.\n'
4009
+ printf 'Comment %s to act on the run; each says when it applies (docs/github-run-control.md in the harness documentation).\n' "$verbs"
4010
+ printf '\n'
4011
+ forge_marker pull-request "$2"
4012
+ } >"$1"
4013
+ }
4014
+
4015
+ # deliver_create <base> <branch> <body_file> [--draft] — one `gh pr create`,
4016
+ # with HARNESS_PR_TOKEN when it is set, else the job's own token.
4017
+ deliver_create() {
4018
+ local base="$1" br="$2" body="$3"
4019
+ shift 3
4020
+ set -- pr create --repo "$FORGE_REPO" --base "$base" --head "$br" "$@" --title "$DELIVER_TITLE" --body-file "$body"
4021
+ if [ -n "${HARNESS_PR_TOKEN-}" ]; then
4022
+ gh_call_token "$HARNESS_PR_TOKEN" "$@"
4023
+ else
4024
+ gh_call "$@"
4025
+ fi
4026
+ }
4027
+
4028
+ # verb_deliver — after a `completed` bundle: find or open the branch's pull
4029
+ # request, post the one `completed` comment, and set `done`. Always exit 0.
4030
+ verb_deliver() {
4031
+ local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
4032
+ local tmp made_tmp="" body="" file target text compare
4033
+ if ! forge_on; then
4034
+ echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4035
+ exit "$EXIT_OK"
4036
+ fi
4037
+ hr_remote_names_var
4038
+ status=$(hr_remote_status_get "$bundle_dir/$HR_REMOTE_STATUS_FILE" status 2>/dev/null) || status=""
4039
+ if [ "$status" != completed ]; then
4040
+ echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
4041
+ exit "$EXIT_OK"
4042
+ fi
4043
+ forge_repo_var || exit "$EXIT_OK"
4044
+
4045
+ forge_fetch_branch "$branch"
4046
+ forge_issue_var "$branch" || FORGE_ISSUE=""
4047
+ forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4048
+
4049
+ tmp="${RUNNER_TEMP-}"
4050
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4051
+ tmp=$(mktemp -d) || tmp=""
4052
+ made_tmp="$tmp"
4053
+ fi
4054
+
4055
+ if [ -n "$FORGE_PR" ]; then
4056
+ pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4057
+ echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
4058
+ elif [ "$lookup_failed" -eq 0 ]; then
4059
+ base=$(hr_default_branch "$root" 2>/dev/null) || base=""
4060
+ deliver_title_var "$branch"
4061
+ if [ -z "$base" ]; then
4062
+ create_err="the configured defaultBranch could not be read"
4063
+ elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
4064
+ create_err="its body file could not be written"
4065
+ elif deliver_create "$base" "$branch" "$body" --draft; then
4066
+ opened=1
4067
+ else
4068
+ create_err="$GH_ERR"
4069
+ case "$GH_ERR" in
4070
+ *"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
4071
+ *)
4072
+ # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4073
+ echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4074
+ if deliver_create "$base" "$branch" "$body"; then
4075
+ opened=1
4076
+ else
4077
+ create_err="$GH_ERR"
4078
+ fi ;;
4079
+ esac
4080
+ fi
4081
+ [ -z "$body" ] || rm -f "$body"
4082
+ if [ "$opened" -eq 1 ]; then
4083
+ FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
4084
+ if [ -n "$FORGE_PR" ]; then
4085
+ pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4086
+ echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
4087
+ else
4088
+ opened=0
4089
+ create_err="gh printed no pull request URL"
4090
+ fi
4091
+ fi
4092
+ fi
4093
+
4094
+ compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4095
+ if [ -z "$pr_url" ]; then
4096
+ target="$FORGE_ISSUE"
4097
+ if [ "$forbidden" -eq 1 ]; then
4098
+ 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"
4099
+ else
4100
+ 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"
4101
+ fi
4102
+ elif [ "$opened" -eq 0 ]; then
4103
+ target="$FORGE_PR"
4104
+ text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
4105
+ elif [ -n "$FORGE_ISSUE" ]; then
4106
+ target="$FORGE_ISSUE"
4107
+ text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
4108
+ Review it there; a review that requests changes starts another round."
4109
+ else
4110
+ target="$FORGE_PR"
4111
+ text="The harness run on \`$branch\` completed and opened this pull request. Review it; a review that requests changes starts another round."
4112
+ fi
4113
+ if hr_phase_enabled "$root" qa; then
4114
+ text="$text
4115
+
4116
+ The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
4117
+ fi
4118
+
4119
+ if [ -z "$target" ]; then
4120
+ echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4121
+ elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
4122
+ {
4123
+ printf '%s\n' "$text"
4124
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4125
+ } >"$file"
4126
+ forge_comment "$target" completed "$branch" "$file" || :
4127
+ rm -f "$file"
4128
+ else
4129
+ echo "remote-run.sh: deliver: cannot create the comment file for #$target; no comment posted" >&2
4130
+ fi
4131
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4132
+
4133
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
4134
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
4135
+ echo "remote-run.sh: deliver: completed on $branch reported${target:+ on #$target}"
4136
+ exit "$EXIT_OK"
4137
+ }
4138
+
4139
+ # ---------------------------------------------------------------------------
4140
+ # `control` — the comment adapter: one GitHub event, one harness action on one
4141
+ # branch, through the child verbs. Event text is data, as in `trigger`.
4142
+ # ---------------------------------------------------------------------------
4143
+
4144
+ CONTROL_BRANCH=""
4145
+ CONTROL_NUMBER=""
4146
+ CONTROL_ACTOR=""
4147
+ CONTROL_VERB=""
4148
+ CONTROL_ARGS=""
4149
+ CONTROL_BODY=""
4150
+ control_tmp=""
4151
+ # Directories control_state_var created, removed on exit.
4152
+ control_dirs=""
4153
+
4154
+ control_cleanup() {
4155
+ local d
4156
+ for d in $control_dirs; do
4157
+ rm -rf -- "$d"
4158
+ done
4159
+ return 0
4160
+ }
4161
+
4162
+ # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
4163
+ # 1, after an `::error::` line, when it cannot be posted.
4164
+ control_post() {
4165
+ local text="$1" file status=0
4166
+ if ! forge_repo_var; then
4167
+ echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4168
+ return 1
4169
+ fi
4170
+ if ! file=$(mktemp "$control_tmp/harness-control-reply.XXXXXX"); then
4171
+ echo "::error::remote-run.sh: control: cannot create the reply file for #$CONTROL_NUMBER under '$control_tmp'"
4172
+ return 1
4173
+ fi
4174
+ {
4175
+ printf '%s\n' "$text"
4176
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4177
+ } >"$file"
4178
+ if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
4179
+ echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4180
+ status=1
4181
+ fi
4182
+ rm -f "$file"
4183
+ return "$status"
4184
+ }
4185
+
4186
+ # control_reply <exit> <text> — control_post <text>, then exit <exit>; a reply
4187
+ # that cannot be posted makes the exit 3.
4188
+ control_reply() {
4189
+ control_post "$2" || exit "$EXIT_GH"
4190
+ exit "$1"
4191
+ }
4192
+
4193
+ # control_refuse <exit> <reason> <way on> — the refusal reply, then exit.
4194
+ control_refuse() {
4195
+ echo "remote-run.sh: control: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` refused: $2" >&2
4196
+ control_reply "$1" "@$CONTROL_ACTOR: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` was not run: $2. $3"
4197
+ }
4198
+
4199
+ # control_child <out_file> <args...> — run this script as a child with stderr
4200
+ # captured; CHILD_STATUS is its exit, CHILD_LAST its last non-empty stderr line,
4201
+ # which is also echoed to this process's stderr.
4202
+ CHILD_STATUS=0
4203
+ CHILD_LAST=""
4204
+ control_child() {
4205
+ local out="$1" errfile
4206
+ shift
4207
+ CHILD_STATUS=0
4208
+ CHILD_LAST=""
4209
+ errfile=$(mktemp "$control_tmp/harness-control-err.XXXXXX") || errfile=/dev/null
4210
+ bash "$script_dir/remote-run.sh" "$@" >"$out" 2>"$errfile" || CHILD_STATUS=$?
4211
+ if [ "$errfile" != /dev/null ]; then
4212
+ cat "$errfile" >&2
4213
+ CHILD_LAST=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
4214
+ rm -f "$errfile"
4215
+ fi
4216
+ [ -n "$CHILD_LAST" ] || CHILD_LAST="exit $CHILD_STATUS, no message"
4217
+ }
4218
+
4219
+ # control_state_var <branch> — the branch's newest remote state, read by a
4220
+ # `fetch` child into a fresh directory: CS_STATE, CS_REASON, CS_ENGINE,
4221
+ # CS_OPEN, CS_DETAIL, CS_URL, CS_RUN_STATUS and CS_DIR. 1 with CS_ERR on a
4222
+ # failed child.
4223
+ CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
4224
+ CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
4225
+ control_state_var() {
4226
+ local out line key value
4227
+ CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
4228
+ CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
4229
+ if ! CS_DIR=$(mktemp -d "$control_tmp/harness-control-fetch.XXXXXX"); then
4230
+ CS_DIR=""
4231
+ CS_ERR="a fetch directory could not be created under '$control_tmp'"
4232
+ return 1
4233
+ fi
4234
+ control_dirs="$control_dirs $CS_DIR"
4235
+ out="$CS_DIR.out"
4236
+ control_dirs="$control_dirs $out"
4237
+ control_child "$out" fetch "$1" "$CS_DIR" --repo "$root"
4238
+ if [ "$CHILD_STATUS" -ne 0 ]; then
4239
+ CS_ERR="$CHILD_LAST"
4240
+ return 1
4241
+ fi
4242
+ while IFS= read -r line; do
4243
+ key=${line%%:*}
4244
+ value=${line#*: }
4245
+ [ "$value" != "$line" ] || value=""
4246
+ case "$key" in
4247
+ state) CS_STATE="$value" ;;
4248
+ pause_reason) CS_REASON="$value" ;;
4249
+ engine) CS_ENGINE="$value" ;;
4250
+ open_questions) CS_OPEN="$value" ;;
4251
+ detail) CS_DETAIL="$value" ;;
4252
+ run_url) CS_URL="$value" ;;
4253
+ run_status) CS_RUN_STATUS="$value" ;;
4254
+ esac
4255
+ done <"$out"
4256
+ return 0
4257
+ }
4258
+
4259
+ # control_check_branch <branch> — the refusals both paths share, in order: a
4260
+ # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4261
+ # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
4262
+ control_check_branch() {
4263
+ local b="$1" protected=0
4264
+ if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
4265
+ control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
4266
+ fi
4267
+ hr_branch_is_protected "$root" "$b" || protected=$?
4268
+ case "$protected" in
4269
+ 1) ;;
4270
+ 0) control_refuse "$EXIT_REFUSED" "\`$b\` is a protected branch, which the harness never acts on" \
4271
+ "Comment on the pull request of the run's own branch instead." ;;
4272
+ *) control_refuse "$EXIT_REFUSED" "whether \`$b\` is protected could not be judged from \`harness.config.json\`" \
4273
+ "Fix the configuration on the default branch, then comment again." ;;
4274
+ esac
4275
+ forge_fetch_branch "$b"
4276
+ if ! forge_recognised "$b"; then
4277
+ control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
4278
+ "Only a branch a harness run works on can be commanded."
4279
+ fi
4280
+ CONTROL_BRANCH="$b"
4281
+ }
4282
+
4283
+ # control_branch_from_pr <number> — the pull request's head, refused for a
4284
+ # fork, a pull request that is not open, and control_check_branch's refusals.
4285
+ control_branch_from_pr() {
4286
+ local head cross state
4287
+ if ! gh_call pr view "$1" --repo "$FORGE_REPO" --json headRefName,isCrossRepository,state; then
4288
+ control_refuse "$EXIT_GH" "pull request #$1 could not be read ($GH_ERR)" "Comment again to retry."
4289
+ fi
4290
+ head=$(printf '%s' "$GH_OUT" | jq -r '.headRefName // empty' 2>/dev/null) || head=""
4291
+ cross=$(printf '%s' "$GH_OUT" | jq -r '.isCrossRepository | tostring' 2>/dev/null) || cross=""
4292
+ state=$(printf '%s' "$GH_OUT" | jq -r '.state // empty' 2>/dev/null) || state=""
4293
+ # A fork's head is never checked out or run: this event carries this
4294
+ # repository's secrets (docs/github-integration-research.md -> C2).
4295
+ if [ "$cross" != false ]; then
4296
+ control_refuse "$EXIT_REFUSED" "pull request #$1 comes from a fork, and the harness never acts on a fork's pull request" \
4297
+ "Push the branch to this repository and open the pull request from there."
4298
+ fi
4299
+ if [ "$state" != OPEN ]; then
4300
+ control_refuse "$EXIT_REFUSED" "pull request #$1 is ${state:-in an unknown state}, not open" "Reopen it, then comment again."
4301
+ fi
4302
+ control_check_branch "$head"
4303
+ }
4304
+
4305
+ # control_branch_from_issue <number> — the branch of the issue's last genuine
4306
+ # start comment: by `github-actions[bot]`, its first line opening with the
4307
+ # trigger's start sentence and a backticked <b>, and its last non-empty line
4308
+ # exactly `forge_marker started <b>`. A marker anywhere else is never trusted.
4309
+ control_branch_from_issue() {
4310
+ local count i login body first last b found="" lead='Started a harness run on the branch `'
4311
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$1/comments" --jq '.[] | {login: .user.login, body: .body}'; then
4312
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry."
4313
+ fi
4314
+ count=$(printf '%s' "$GH_OUT" | jq -s 'length' 2>/dev/null) || count=""
4315
+ case "$count" in
4316
+ ''|*[!0-9]*)
4317
+ GH_ERR="its comment list is not the expected JSON"
4318
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry." ;;
4319
+ esac
4320
+ for ((i = 0; i < count; i++)); do
4321
+ login=$(printf '%s' "$GH_OUT" | jq -s -r --argjson i "$i" '.[$i].login // ""' 2>/dev/null) || continue
4322
+ [ "$login" = 'github-actions[bot]' ] || continue
4323
+ body=$(printf '%s' "$GH_OUT" | jq -s -j --argjson i "$i" '.[$i].body // ""' 2>/dev/null) || continue
4324
+ body=${body//$'\r'$'\n'/$'\n'}
4325
+ body=${body%$'\r'}
4326
+ case "$body" in
4327
+ "$lead"*) ;;
4328
+ *) continue ;;
4329
+ esac
4330
+ b=${body#"$lead"}
4331
+ b=${b%%$'\n'*}
4332
+ case "$b" in
4333
+ *'`'*) b=${b%%'`'*} ;;
4334
+ *) continue ;;
4335
+ esac
4336
+ [ -n "$b" ] || continue
4337
+ last=$(printf '%s\n' "$body" | grep -v '^[[:space:]]*$' | tail -n 1)
4338
+ [ "$last" = "$(forge_marker started "$b")" ] || continue
4339
+ found="$b"
4340
+ done
4341
+ if [ -z "$found" ]; then
4342
+ control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4343
+ "Comment on the pull request of the run's branch instead."
4344
+ fi
4345
+ control_check_branch "$found"
4346
+ }
4347
+
4348
+ # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
4349
+ control_verb_handled() {
4350
+ case "$1" in
4351
+ answer|pause|stop|resume|clear) return 0 ;;
4352
+ esac
4353
+ return 1
4354
+ }
4355
+
4356
+ # control_resume_dispatch <reply> [<dispatch flag>] — the resume dispatch the
4357
+ # local relay sends for CS_ENGINE; on 0, <reply>, then `running` on the run's
4358
+ # issue and pull request. An empty engine is refused, never guessed: the run
4359
+ # workflow's `engine` input defaults to `task`.
4360
+ control_resume_dispatch() {
4361
+ local done_text="$1" out status route clear=""
4362
+ shift
4363
+ if [ -z "$CS_ENGINE" ]; then
4364
+ # The run's own engine is unrecorded, so the route names the choice.
4365
+ route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4366
+ route=${route#or from GitHub: }
4367
+ [ "$#" -eq 0 ] || clear=", with park_loop_clear true as well"
4368
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`$CS_STATE\`${CS_REASON:+, \`$CS_REASON\`}) records no engine, and the harness does not guess one" \
4369
+ "Resume it with the **Run workflow** form instead: $route$clear."
4370
+ fi
4371
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4372
+ control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume pause "$@" --chain 0 --repo "$root"
4373
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4374
+ case "$CHILD_STATUS" in
4375
+ 0) ;;
4376
+ 2) control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again once that is fixed." ;;
4377
+ *) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
4378
+ esac
4379
+ status="$EXIT_OK"
4380
+ control_post "$done_text" || status="$EXIT_GH"
4381
+ # The job posts its own `resumed` comment; the labels say `running` now.
4382
+ forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4383
+ forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4384
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
4385
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
4386
+ exit "$status"
4387
+ }
4388
+
4389
+ control_resume() {
4390
+ local open
4391
+ control_state_var "$CONTROL_BRANCH" \
4392
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4393
+ case "$CS_STATE" in
4394
+ paused)
4395
+ # Every pause reason, `expired` and `killed` included, resumes from the
4396
+ # committed ledger, as the local route does.
4397
+ control_resume_dispatch "Resume requested by @$CONTROL_ACTOR: \`$CONTROL_BRANCH\` continues from its committed ledger." ;;
4398
+ park_loop)
4399
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard" \
4400
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4401
+ parked)
4402
+ open=""
4403
+ [ -z "$CS_OPEN" ] || open=" (open: $CS_OPEN)"
4404
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`parked\`, waiting for an answer$open" \
4405
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer to question <n> on the lines below it." ;;
4406
+ running)
4407
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is already \`running\`" "Nothing needs resuming." ;;
4408
+ *)
4409
+ control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4410
+ "A finished run continues by a review requesting changes on its pull request, or by applying the trigger label to its issue again." ;;
4411
+ esac
4412
+ }
4413
+
4414
+ control_clear() {
4415
+ control_state_var "$CONTROL_BRANCH" \
4416
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4417
+ if [ "$CS_STATE" != park_loop ]; then
4418
+ control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4419
+ "Only a run held by the park-loop guard is cleared."
4420
+ fi
4421
+ # On GitHub, typing `clear` is the confirmation `branch-resume` asks for.
4422
+ control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; the run resumes from its committed ledger." \
4423
+ --park-loop-clear
4424
+ }
4425
+
4426
+ control_pause() {
4427
+ local out
4428
+ control_state_var "$CONTROL_BRANCH" \
4429
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4430
+ if [ "$CS_STATE" != running ]; then
4431
+ control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_STATE\`" \
4432
+ "Nothing needs pausing."
4433
+ fi
4434
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4435
+ control_child "$out" pause "$CONTROL_BRANCH" --repo "$root"
4436
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4437
+ if [ "$CHILD_STATUS" -ne 0 ]; then
4438
+ control_refuse "$EXIT_GH" "the pause could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE pause\` again to retry."
4439
+ fi
4440
+ control_reply "$EXIT_OK" "Pause requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` yields at its next clean checkpoint, and a paused comment follows."
4441
+ }
4442
+
4443
+ control_stop() {
4444
+ local out
4445
+ control_state_var "$CONTROL_BRANCH" \
4446
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4447
+ if [ "$CS_STATE" = none ]; then
4448
+ control_refuse "$EXIT_REFUSED" "there is no harness run on \`$CONTROL_BRANCH\` to stop" "Nothing needs stopping."
4449
+ fi
4450
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4451
+ control_child "$out" stop "$CONTROL_BRANCH" --actor "$CONTROL_ACTOR" --repo "$root"
4452
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4453
+ case "$CHILD_STATUS" in
4454
+ 0)
4455
+ # Always replied here: `stop`'s own `stopped` comment goes to the run's
4456
+ # target, which need not be the item the command was typed on.
4457
+ control_reply "$EXIT_OK" "Stop requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` is stopped." ;;
4458
+ 3)
4459
+ control_refuse "$EXIT_GH" "the stop of \`$CONTROL_BRANCH\` is partial ($CHILD_LAST)" \
4460
+ "Comment \`$COMMAND_HANDLE stop\` again to finish it." ;;
4461
+ *)
4462
+ control_refuse "$EXIT_GH" "the stop could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE stop\` again to retry." ;;
4463
+ esac
4464
+ }
4465
+
4466
+ # control_answer — one answer, one `resume: answer` dispatch with one entry. A
4467
+ # park left partly answered is safe: run_job stops an `answer` job whose park
4468
+ # is not fully answered before any session, and its bundle then carries the
4469
+ # `answer_<n>.md` restore wrote, so the next answer's job finds the set complete.
4470
+ control_answer() {
4471
+ local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK"
4472
+ first=${CONTROL_ARGS%%[$' \t']*}
4473
+ if [[ "$first" =~ ^[1-9][0-9]*$ ]]; then
4474
+ n="$first"
4475
+ short=${CONTROL_ARGS#"$first"}
4476
+ short=${short#"${short%%[!$' \t']*}"}
4477
+ else
4478
+ short="$CONTROL_ARGS"
4479
+ fi
4480
+ case "$CONTROL_BODY" in
4481
+ *$'\n'*) below=${CONTROL_BODY#*$'\n'} ;;
4482
+ esac
4483
+ below=${below//$'\r'$'\n'/$'\n'}
4484
+ below=${below%$'\r'}
4485
+ if [[ "$below" =~ ^[[:space:]]*$ ]]; then
4486
+ text="$short"
4487
+ else
4488
+ text="$below"
4489
+ fi
4490
+ if [[ "$text" =~ ^[[:space:]]*$ ]]; then
4491
+ control_refuse "$EXIT_REFUSED" "the answer is empty" \
4492
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer on the lines below it."
4493
+ fi
4494
+
4495
+ control_state_var "$CONTROL_BRANCH" \
4496
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4497
+ case "$CS_STATE:$CS_REASON" in
4498
+ park_loop:*)
4499
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard, not waiting for an answer" \
4500
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4501
+ paused:expired)
4502
+ # An expired bundle is reported as expired, never as no park.
4503
+ control_refuse "$EXIT_REFUSED" "${CS_DETAIL:-the state bundle of the run has expired}; the park's questions can no longer be answered here" \
4504
+ "Comment \`$COMMAND_HANDLE resume\` to resume from the committed ledger." ;;
4505
+ running:*)
4506
+ # Refused rather than queued: a newer pending run in the per-branch
4507
+ # concurrency group could cancel a queued one.
4508
+ control_refuse "$EXIT_REFUSED" "a job of the run on \`$CONTROL_BRANCH\` is in progress${CS_URL:+ ($CS_URL)}" \
4509
+ "Send the answer again once it finishes."
4510
+ ;;
4511
+ parked:*) ;;
4512
+ *)
4513
+ control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4514
+ "Nothing is waiting for an answer." ;;
4515
+ esac
4516
+ if [ -z "$CS_OPEN" ]; then
4517
+ control_refuse "$EXIT_REFUSED" "only a parked run with an open question can be answered, and the run on \`$CONTROL_BRANCH\` is \`parked\` with none open" \
4518
+ "Nothing is waiting for an answer."
4519
+ fi
4520
+ for v in $CS_OPEN; do
4521
+ open_list="$open_list${open_list:+, }$v"
4522
+ cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
4523
+ done
4524
+ if [ -z "$n" ]; then
4525
+ case "$CS_OPEN" in
4526
+ *' '*)
4527
+ control_refuse "$EXIT_REFUSED" "questions $open_list are open, so the command must name one" \
4528
+ "Answer each with its own comment: $cmds." ;;
4529
+ esac
4530
+ n="$CS_OPEN"
4531
+ fi
4532
+ case " $CS_OPEN " in
4533
+ *" $n "*) ;;
4534
+ *)
4535
+ control_refuse "$EXIT_REFUSED" "question $n is not open; the open questions are $open_list" \
4536
+ "Answer one of them: $cmds." ;;
4537
+ esac
4538
+ if [ -z "$CS_ENGINE" ]; then
4539
+ route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4540
+ route=${route#or from GitHub: }
4541
+ form="resume answer and answers \`{\"$n\": \"<the answer>\"}\`"
4542
+ route=${route/and resume pause/$form}
4543
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`parked\`) records no engine, and the harness does not guess one" \
4544
+ "Answer it with the **Run workflow** form instead: $route."
4545
+ fi
4546
+
4547
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-answer.XXXXXX"); then
4548
+ control_refuse "$EXIT_GH" "an answer directory could not be created under '$control_tmp'" "Comment again to retry."
4549
+ fi
4550
+ control_dirs="$control_dirs $dir"
4551
+ # The answer is untrusted data: written by printf, never sourced.
4552
+ if ! mkdir "$dir/answers" || ! printf '%s' "$text" >"$dir/answers/answer_$n.md"; then
4553
+ control_refuse "$EXIT_GH" "the answer could not be written under '$dir'" "Comment again to retry."
4554
+ fi
4555
+ out="$dir/dispatch.out"
4556
+ control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume answer \
4557
+ --answers-from "$dir/answers" --indexes "$n" --chain 0 --repo "$root"
4558
+ cat "$out" 2>/dev/null || :
4559
+ case "$CHILD_STATUS" in
4560
+ 0) ;;
4561
+ 2)
4562
+ case "$CHILD_LAST" in
4563
+ *"workflow_dispatch limit"*)
4564
+ control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
4565
+ "Shorten the answer, or commit it to a file on \`$CONTROL_BRANCH\` and name that file in a shorter answer." ;;
4566
+ esac
4567
+ control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
4568
+ "Comment \`$COMMAND_HANDLE answer $n\` again once that is fixed." ;;
4569
+ *)
4570
+ control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
4571
+ "Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
4572
+ esac
4573
+
4574
+ cmds=""
4575
+ for v in $CS_OPEN; do
4576
+ [ "$v" != "$n" ] || continue
4577
+ rest="$rest${rest:+, }$v"
4578
+ cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
4579
+ done
4580
+ if [ -n "$rest" ]; then
4581
+ # The label stays `parked` until the last answer: only that job resumes.
4582
+ control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; question(s) $rest still need an answer: $cmds."
4583
+ fi
4584
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
4585
+ || status="$EXIT_GH"
4586
+ forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4587
+ forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4588
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
4589
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
4590
+ exit "$status"
4591
+ }
4592
+
4593
+ # The fields of a `pull_request_review` event control reads beyond the shared ones.
4594
+ REVIEW_ID=""
4595
+ REVIEW_URL=""
4596
+ REVIEW_AT=""
4597
+ REVIEW_HEAD=""
4598
+
4599
+ # control_review_story — refused unless origin's tip of CONTROL_BRANCH carries
4600
+ # its story index, which the round's statistics step reads.
4601
+ control_review_story() {
4602
+ local state_rel
4603
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
4604
+ state_rel="${state_rel%/}"
4605
+ if [ -z "$state_rel" ] \
4606
+ || ! git -C "$root" cat-file -e "refs/remotes/origin/$CONTROL_BRANCH:$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md" 2>/dev/null; then
4607
+ control_refuse "$EXIT_REFUSED" "\`$CONTROL_BRANCH\` carries no story index (\`$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md\`), which a user-review round reads, so the round cannot start on this branch" \
4608
+ "Review a branch whose run has planned its stories."
4609
+ fi
4610
+ }
4611
+
4612
+ # round_collect <pr_number> <out_file> — the cumulative round of the global
4613
+ # `branch`'s pull request <pr_number>, shared by `control` and `collect`: every
4614
+ # submitted review (never `PENDING`) that requests changes or carries a
4615
+ # non-blank body, and every inline comment, no earlier round consumed, by
4616
+ # every author `authorise_actor` accepts. What earlier rounds consumed is
4617
+ # read from the marker lines of their files on origin's tip; with none marked,
4618
+ # the boundary is the committer time of the newest round file. A comment
4619
+ # belonging to a pending review is pending whatever its `created_at`: a draft
4620
+ # comment is created before its review is submitted. RC_EVENT, set by the
4621
+ # caller, is the event's own review as a JSON object, merged when the listing
4622
+ # lacks it. Sets RC_REVIEWS (the kept reviews requesting changes only),
4623
+ # RC_REVIEWERS (distinct logins of every review written, comma-joined, in
4624
+ # order) and RC_EVENT_ROUND (the round whose marker records RC_EVENT's id).
4625
+ # Returns 0 with <out_file> written; 1 when no review requesting changes is
4626
+ # pending, writing nothing and recording nothing, so a *Comment* or *Approve*
4627
+ # review rides along in the next round one requesting changes starts; 3 a listing or a permission call failed; 4 the
4628
+ # previous rounds or <out_file> could not be read or written. RC_ERR holds why.
4629
+ RC_EVENT=""
4630
+ RC_REVIEWS=0
4631
+ RC_REVIEWERS=""
4632
+ RC_EVENT_ROUND=""
4633
+ RC_ERR=""
4634
+ # round_markers_read <event_review_id> — what the global `branch`'s rounds on
4635
+ # origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
4636
+ # recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
4637
+ # highest-numbered marked round's `collected_at`, empty when none is marked),
4638
+ # RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
4639
+ # 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
4640
+ # on its own, to name the round a review in flight is already part of.
4641
+ RC_SEEN_R=","
4642
+ RC_SEEN_C=","
4643
+ RC_MARKED_AT=""
4644
+ RC_STATE_REL=""
4645
+ round_markers_read() {
4646
+ local event_id="${1-}" names name path n line marked_max=0
4647
+ RC_SEEN_R=","
4648
+ RC_SEEN_C=","
4649
+ RC_MARKED_AT=""
4650
+ RC_EVENT_ROUND=""
4651
+ RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
4652
+ RC_STATE_REL="${RC_STATE_REL%/}"
4653
+ if [ -z "$RC_STATE_REL" ]; then
4654
+ RC_ERR="the state directory under '$root' could not be resolved"
4655
+ return 4
4656
+ fi
4657
+ if ! names=$(git -C "$root" ls-tree --name-only "refs/remotes/origin/$branch" -- "$RC_STATE_REL/user_reviews/" 2>/dev/null); then
4658
+ RC_ERR="the previous rounds of \`$branch\` could not be listed"
4659
+ return 4
4660
+ fi
4661
+ while IFS= read -r path; do
4662
+ name="${path##*/}"
4663
+ [[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
4664
+ [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
4665
+ n="${BASH_REMATCH[3]:-1}"
4666
+ n=$((10#$n))
4667
+ if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
4668
+ RC_ERR="the previous round \`$path\` could not be read"
4669
+ return 4
4670
+ fi
4671
+ # The last marker line wins; a round placed before markers carries none.
4672
+ line=$(printf '%s\n' "$line" | grep -F "$COMMENT_MARKER round collected_at=" | tail -n 1)
4673
+ [[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
4674
+ RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
4675
+ RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
4676
+ if [ "$n" -gt "$marked_max" ]; then
4677
+ marked_max="$n"
4678
+ RC_MARKED_AT="${BASH_REMATCH[1]}"
4679
+ fi
4680
+ case ",${BASH_REMATCH[2]}," in
4681
+ *",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
4682
+ esac
4683
+ done <<NAMES
4684
+ $names
4685
+ NAMES
4686
+ return 0
4687
+ }
4688
+
4689
+ round_collect() {
4690
+ local pr="$1" file="$2" collected_at state_rel event_id=""
4691
+ local seen_r seen_c marked_at since="" tmp status
4692
+ local kept authors login type allowed="," count text
4693
+ RC_REVIEWS=0
4694
+ RC_REVIEWERS=""
4695
+ RC_EVENT_ROUND=""
4696
+ RC_ERR=""
4697
+ collected_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
4698
+ [ -z "$RC_EVENT" ] || event_id=$(printf '%s' "$RC_EVENT" | jq -r '.id // empty' 2>/dev/null) || event_id=""
4699
+
4700
+ round_markers_read "$event_id" || return 4
4701
+ state_rel="$RC_STATE_REL"
4702
+ seen_r="$RC_SEEN_R"
4703
+ seen_c="$RC_SEEN_C"
4704
+ marked_at="$RC_MARKED_AT"
4705
+ # Epoch seconds, so the comparison with `submitted_at` / `created_at` reads no time zone.
4706
+ if [ -n "$marked_at" ]; then
4707
+ if ! since=$(jq -n -r --arg t "$marked_at" --argjson o "$ROUND_OVERLAP_SECS" '($t | fromdateiso8601) - $o' 2>/dev/null); then
4708
+ RC_ERR="the previous round's collected_at '$marked_at' is not a UTC time"
4709
+ return 4
4710
+ fi
4711
+ elif ! since=$(git -C "$root" log -1 --format=%ct "refs/remotes/origin/$branch" -- \
4712
+ "$state_rel/user_reviews/${branch}_review.md" \
4713
+ "$state_rel/user_reviews/${branch}_review_[0-9]*.md" 2>/dev/null); then
4714
+ RC_ERR="the previous round of \`$branch\` could not be read"
4715
+ return 4
4716
+ fi
4717
+
4718
+ if ! tmp=$(mktemp -d); then
4719
+ RC_ERR="a collection directory could not be created"
4720
+ return 4
4721
+ fi
4722
+ if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/reviews"; then
4723
+ RC_ERR="the reviews of pull request #$pr could not be read ($GH_ERR)"
4724
+ rm -rf -- "$tmp"
4725
+ return 3
4726
+ fi
4727
+ printf '%s' "$GH_OUT" >"$tmp/reviews.json"
4728
+ if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/comments"; then
4729
+ RC_ERR="the inline comments of pull request #$pr could not be read ($GH_ERR)"
4730
+ rm -rf -- "$tmp"
4731
+ return 3
4732
+ fi
4733
+ printf '%s' "$GH_OUT" >"$tmp/comments.json"
4734
+
4735
+ # A paginated listing is one JSON array per page; jq selects, so every
4736
+ # listed field is data and never shell source.
4737
+ if ! kept=$(jq -n -c --slurpfile R "$tmp/reviews.json" --slurpfile C "$tmp/comments.json" \
4738
+ --argjson event "${RC_EVENT:-null}" --arg since "$since" --arg state "$REVIEW_ROUND_STATE" \
4739
+ --arg marker "$COMMENT_MARKER" --arg seen_r "$seen_r" --arg seen_c "$seen_c" '
4740
+ def flat: [ .[] | if type == "array" then .[] else error("not a page") end ];
4741
+ def unseen($ids): ("," + tostring + ",") as $k | ($ids | contains($k)) | not;
4742
+ def since_ok($t): $since == ""
4743
+ or ((($t // "") | try fromdateiso8601 catch 0) >= ($since | tonumber));
4744
+ ($R | flat) as $listed
4745
+ | (if $event == null or any($listed[]; .id == $event.id) then $listed else $listed + [$event] end)
4746
+ | [ .[] | ((.state // "") | ascii_downcase) as $s
4747
+ | select($s != "" and $s != "pending")
4748
+ | select($s == $state or ((.body // "") | test("\\S")))
4749
+ | select(((.body // "") | contains($marker)) | not)
4750
+ | select(.id | unseen($seen_r))
4751
+ | select(since_ok(.submitted_at)) ]
4752
+ | sort_by([.submitted_at, .id]) as $reviews
4753
+ | ($reviews | map(.id)) as $ids
4754
+ | ($C | flat)
4755
+ | [ .[] | select(.id | unseen($seen_c))
4756
+ | select(((.body // "") | contains($marker)) | not)
4757
+ | select(since_ok(.created_at) or (.pull_request_review_id as $r | any($ids[]; . == $r))) ]
4758
+ | sort_by([.created_at, .id]) as $comments
4759
+ | {reviews: $reviews, comments: $comments}' 2>/dev/null); then
4760
+ RC_ERR="the reviews or inline comments of pull request #$pr are not the expected JSON"
4761
+ rm -rf -- "$tmp"
4762
+ return 3
4763
+ fi
4764
+ rm -rf -- "$tmp"
4765
+
4766
+ # One permission answer per distinct author; a refused author's items are
4767
+ # dropped with one line, and a failed call fails the collection.
4768
+ authors=$(printf '%s' "$kept" | jq -r '
4769
+ [ (.reviews[], .comments[]) | {l: (.user.login // ""), t: (.user.type // "")} ]
4770
+ | reduce .[] as $a ([]; if any(.[]; .l == $a.l) then . else . + [$a] end)
4771
+ | .[] | "\(.l)\t\(.t)"')
4772
+ while IFS=$'\t' read -r login type; do
4773
+ [ -n "$login$type" ] || continue
4774
+ status=0
4775
+ authorise_actor "$login" "$type" || status=$?
4776
+ case "$status" in
4777
+ 0) allowed="$allowed$login," ;;
4778
+ 4)
4779
+ RC_ERR="${AUTH_WHY%.}"
4780
+ return 3 ;;
4781
+ *)
4782
+ count=$(printf '%s' "$kept" | jq -r --arg l "$login" '[ (.reviews[], .comments[]) | select((.user.login // "") == $l) ] | length')
4783
+ echo "remote-run.sh: round: dropped $count item(s) by @${login:-(no login)}: $AUTH_WHY" ;;
4784
+ esac
4785
+ done <<AUTHORS
4786
+ $authors
4787
+ AUTHORS
4788
+ kept=$(printf '%s' "$kept" | jq -c --arg allowed "$allowed" '
4789
+ def ok: ("," + (.user.login // "") + ",") as $k | ($allowed | contains($k)) and (.user.login // "") != "";
4790
+ {reviews: [ .reviews[] | select(ok) ], comments: [ .comments[] | select(ok) ]}')
4791
+
4792
+ RC_REVIEWS=$(printf '%s' "$kept" | jq -r --arg s "$REVIEW_ROUND_STATE" \
4793
+ '[ .reviews[] | select(((.state // "") | ascii_downcase) == $s) ] | length')
4794
+ [ "$RC_REVIEWS" -gt 0 ] || return 1
4795
+ RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
4796
+ reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
4797
+
4798
+ # A hunk's fence is one backtick longer than its longest backtick run, at
4799
+ # least three. The trailing `x` keeps the text's final newline through the
4800
+ # substitution.
4801
+ if ! text=$(printf '%s' "$kept" | jq -j --arg pr "$pr" --arg at "$collected_at" --arg marker "$COMMENT_MARKER" --arg state "$REVIEW_ROUND_STATE" '
4802
+ def nl: if endswith("\n") then . else . + "\n" end;
4803
+ .reviews as $rv | .comments as $cm
4804
+ | ($rv | map(
4805
+ ((.state // "") | ascii_downcase) as $s
4806
+ | (" pull request #" + $pr + " (" + (.html_url // "") + ") at " + (.submitted_at // "")) as $on
4807
+ | "## Review by @" + .user.login + "\n\n"
4808
+ + (if (.body // "") == "" then "(The review carries no summary.)\n" else (.body | nl) end)
4809
+ + "\n"
4810
+ + (if $s == $state then "Requested changes on" + $on + ".\n"
4811
+ elif $s == "commented" then "Commented on" + $on + ".\n"
4812
+ elif $s == "approved" then "Approved" + $on + ".\n"
4813
+ elif $s == "dismissed" then "Reviewed" + $on + "; the review has since been dismissed.\n"
4814
+ else "Reviewed" + $on + " (state " + $s + ").\n" end)
4815
+ ) | join("\n"))
4816
+ + (if ($cm | length) == 0 then "" else
4817
+ "\n## Inline comments\n" + ($cm | map(
4818
+ (.diff_hunk // "") as $h
4819
+ | (([$h | match("`+"; "g") | .length] | max) // 0) as $m
4820
+ | ("`" * ([$m + 1, 3] | max)) as $f
4821
+ | "\n### `" + (.path // "") + "`"
4822
+ + (if .line != null then ", line \(.line)"
4823
+ elif .original_line != null then ", original line \(.original_line) (outdated)"
4824
+ else "" end)
4825
+ + "\n\nMade on commit `" + (.original_commit_id // .commit_id // "") + "`.\n"
4826
+ + "By @" + .user.login + ": " + (.html_url // "") + "\n\n"
4827
+ + ((.body // "") | nl)
4828
+ + "\n" + $f + "diff\n" + ($h | nl) + $f + "\n"
4829
+ ) | join(""))
4830
+ end)
4831
+ + "\n" + $marker + " round collected_at=" + $at
4832
+ + " reviews=" + ($rv | map(.id | tostring) | join(","))
4833
+ + " comments=" + ($cm | map(.id | tostring) | join(",")) + " -->\n"
4834
+ + "x"' 2>/dev/null); then
4835
+ RC_ERR="the round could not be rendered"
4836
+ return 4
4837
+ fi
4838
+ if ! printf '%s' "${text%x}" >"$file"; then
4839
+ RC_ERR="the round could not be written to '$file'"
4840
+ return 4
4841
+ fi
4842
+ return 0
4843
+ }
4844
+
4845
+ # The way on of every review reply that started nothing: a review is never
4846
+ # resubmitted to be kept, only to retry now.
4847
+ REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the next round; submit any review requesting changes to retry now."
4848
+
4849
+ # control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
4850
+ # counted as settled, run in a command substitution so that its exit on a
4851
+ # failed read reaches this process as a status, not as an exit with no reply:
4852
+ # BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
4853
+ # failed read.
4854
+ BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
4855
+ control_settled_var() {
4856
+ local dir errfile line status=0
4857
+ BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
4858
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
4859
+ BS_ERR="a state directory could not be created under '$control_tmp'"
4860
+ return 1
4861
+ fi
4862
+ errfile="$dir.err"
4863
+ control_dirs="$control_dirs $dir $errfile"
4864
+ branch="$CONTROL_BRANCH"
4865
+ line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
4866
+ && printf '%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON") || status=$?
4867
+ cat "$errfile" >&2 2>/dev/null || :
4868
+ if [ "$status" -ne 0 ] || [ -z "$line" ]; then
4869
+ BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
4870
+ [ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
4871
+ return 1
4872
+ fi
4873
+ IFS='|' read -r BS_SETTLED BS_STATE BS_REASON <<<"$line"
4874
+ return 0
4875
+ }
4876
+
4877
+ # control_review_in_flight — the reply to a review while CONTROL_BRANCH is in
4878
+ # flight (BS_STATE; empty when unknown), then exit 0: never a refusal, nothing
4879
+ # pushed or dispatched. Names the round whose marker on origin's tip already
4880
+ # records the review, else says it was collected, with the state's way on.
4881
+ control_review_in_flight() {
4882
+ local state="in flight" way=""
4883
+ if [ -n "$BS_STATE" ]; then
4884
+ state="\`$BS_STATE\`"
4885
+ [ -z "$BS_REASON" ] || state="$state (\`$BS_REASON\`)"
4886
+ fi
4887
+ case "$BS_STATE" in
4888
+ parked)
4889
+ way=" The run waits for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it." ;;
4890
+ park_loop)
4891
+ way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
4892
+ paused)
4893
+ if [ "$BS_REASON" = usage ]; then
4894
+ way=" The run resumes by itself once the usage limit resets."
4895
+ else
4896
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
4897
+ fi ;;
4898
+ esac
4899
+ branch="$CONTROL_BRANCH"
4900
+ if round_markers_read "$REVIEW_ID" && [ -n "$RC_EVENT_ROUND" ]; then
4901
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND, which is $state on \`$CONTROL_BRANCH\`."
4902
+ fi
4903
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review was collected. \`$CONTROL_BRANCH\` is $state; when that run finishes, the next user-review round starts by itself from every review requesting changes and every inline comment left since the previous round, yours included. Nothing needs to be submitted again.$way"
4904
+ }
4905
+
4906
+ # control_review — a review requesting changes. In flight: acknowledged and
4907
+ # left on the pull request for the next round. Settled: the cumulative round,
4908
+ # placed and dispatched by a `review` child, the local relay's own verb.
4909
+ control_review() {
4910
+ local dir file out status
4911
+ control_review_story
4912
+ control_settled_var \
4913
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($BS_ERR)" "$REVIEW_RETRY_WAY"
4914
+ [ "$BS_SETTLED" = 1 ] || control_review_in_flight
4915
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-review.XXXXXX"); then
4916
+ control_refuse "$EXIT_PLACEMENT" "a round directory could not be created under '$control_tmp', so nothing was dispatched" \
4917
+ "$REVIEW_RETRY_WAY"
4918
+ fi
4919
+ control_dirs="$control_dirs $dir"
4920
+ file="$dir/review.md"
4921
+ if ! RC_EVENT=$(jq -n -c --argjson id "$REVIEW_ID" --arg body "$CONTROL_BODY" --arg url "$REVIEW_URL" \
4922
+ --arg at "$REVIEW_AT" --arg login "$CONTROL_ACTOR" --arg type "$CONTROL_SENDER_TYPE" \
4923
+ '{id: $id, state: "CHANGES_REQUESTED", body: $body, html_url: $url, submitted_at: $at, user: {login: $login, type: $type}}'); then
4924
+ control_refuse "$EXIT_PLACEMENT" "the review could not be read into the round, so nothing was dispatched" \
4925
+ "$REVIEW_RETRY_WAY"
4926
+ fi
4927
+ branch="$CONTROL_BRANCH"
4928
+ status=0
4929
+ round_collect "$CONTROL_NUMBER" "$file" || status=$?
4930
+ case "$status" in
4931
+ 0) ;;
4932
+ 1)
4933
+ if [ -n "$RC_EVENT_ROUND" ]; then
4934
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND of \`$CONTROL_BRANCH\`; nothing new was collected."
4935
+ fi
4936
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: no review requesting changes is pending on pull request #$CONTROL_NUMBER since the previous round of \`$CONTROL_BRANCH\`, so no round was started." ;;
4937
+ 3)
4938
+ control_refuse "$EXIT_GH" "the round could not be collected: $RC_ERR" "$REVIEW_RETRY_WAY" ;;
4939
+ *)
4940
+ control_refuse "$EXIT_PLACEMENT" "the round could not be collected: $RC_ERR, so nothing was dispatched" \
4941
+ "$REVIEW_RETRY_WAY" ;;
4942
+ esac
4943
+ out="$dir/review.out"
4944
+ set -- review "$CONTROL_BRANCH" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
4945
+ case "$FORGE_SERVER" in
4946
+ https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$CONTROL_NUMBER" ;;
4947
+ esac
4948
+ control_child "$out" "$@" --repo "$root"
4949
+ cat "$out" 2>/dev/null || :
4950
+ case "$CHILD_STATUS" in
4951
+ 0) exit "$EXIT_OK" ;;
4952
+ 2)
4953
+ # The branch became unsettled between the two reads: a run started in
4954
+ # between, so the review waits for it like any review in flight.
4955
+ control_settled_var && [ "$BS_SETTLED" != 1 ] || { BS_STATE=""; BS_REASON=""; }
4956
+ control_review_in_flight ;;
4957
+ 3)
4958
+ control_refuse "$EXIT_GH" "the round is pushed but its dispatch failed ($CHILD_LAST)" \
4959
+ "Re-send the dispatch as that line says." ;;
4960
+ 4)
4961
+ control_refuse "$EXIT_PLACEMENT" "placing the round failed and nothing was dispatched ($CHILD_LAST)" \
4962
+ "$REVIEW_RETRY_WAY" ;;
4963
+ *)
4964
+ control_refuse "$EXIT_GH" "the round could not be started ($CHILD_LAST)" "$REVIEW_RETRY_WAY" ;;
4965
+ esac
4966
+ }
4967
+
4968
+ # control_review_intake — read a `pull_request_review` event; returns 1 when it
4969
+ # is ignored, after one line.
4970
+ control_review_intake() {
4971
+ local action state head_repo
4972
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
4973
+ && event_field '.review.state // ""' && state="$EVENT_VALUE" \
4974
+ && event_field '.review.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
4975
+ && event_field '.review.id // ""' && REVIEW_ID="$EVENT_VALUE" \
4976
+ && event_field '.review.html_url // ""' && REVIEW_URL="$EVENT_VALUE" \
4977
+ && event_field '.review.submitted_at // ""' && REVIEW_AT="$EVENT_VALUE" \
4978
+ && event_field '.pull_request.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
4979
+ && event_field '.pull_request.head.ref // ""' && REVIEW_HEAD="$EVENT_VALUE" \
4980
+ && event_field '.pull_request.head.repo.full_name // ""' && head_repo="$EVENT_VALUE" \
4981
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
4982
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
4983
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
4984
+ exit "$EXIT_USAGE"
4985
+ }
4986
+ if [ "$action" != submitted ]; then
4987
+ echo "remote-run.sh: control: ignored, a review $action, not submitted"
4988
+ return 1
4989
+ fi
4990
+ # The REST API reports states in uppercase, the webhook in lowercase.
4991
+ if [ "$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')" != "$REVIEW_ROUND_STATE" ]; then
4992
+ echo "remote-run.sh: control: ignored, a review whose state is ${state:-empty}, not $REVIEW_ROUND_STATE"
4993
+ return 1
4994
+ fi
4995
+ case "$CONTROL_BODY" in
4996
+ *"$COMMENT_MARKER"*)
4997
+ echo "remote-run.sh: control: ignored, a review carrying the harness's marker"
4998
+ return 1 ;;
4999
+ esac
5000
+ # A fork's review job holds a read-only token, so it is not even replied to.
5001
+ if [ -z "$head_repo" ] || [ "$head_repo" != "${GITHUB_REPOSITORY-}" ]; then
5002
+ echo "remote-run.sh: control: ignored, a review of a head in ${head_repo:-an unnamed repository}, not ${GITHUB_REPOSITORY:-this repository}"
5003
+ return 1
5004
+ fi
5005
+ case "$REVIEW_ID" in
5006
+ ''|*[!0-9]*|0*)
5007
+ echo "remote-run.sh: control: the event carries no review id" >&2
5008
+ exit "$EXIT_USAGE" ;;
5009
+ esac
5010
+ CONTROL_VERB=review
5011
+ return 0
5012
+ }
5013
+
5014
+ # control_comment_intake — read an `issue_comment` event; returns 1 when it is
5015
+ # ignored, after one line.
5016
+ CONTROL_SENDER_TYPE=""
5017
+ CONTROL_IS_PR=""
5018
+ control_comment_intake() {
5019
+ local action first word rest handle
5020
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5021
+ && event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
5022
+ && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5023
+ && event_field '.issue.pull_request.url // ""' && CONTROL_IS_PR="$EVENT_VALUE" \
5024
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5025
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
5026
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
5027
+ exit "$EXIT_USAGE"
5028
+ }
5029
+
5030
+ if [ "$action" != created ]; then
5031
+ echo "remote-run.sh: control: ignored, a comment $action, not created"
5032
+ return 1
5033
+ fi
5034
+ case "$CONTROL_BODY" in
5035
+ *"$COMMENT_MARKER"*)
5036
+ echo "remote-run.sh: control: ignored, a comment the harness posted"
5037
+ return 1 ;;
5038
+ esac
5039
+ first=${CONTROL_BODY%%$'\n'*}
5040
+ first=${first%$'\r'}
5041
+ first=${first#"${first%%[!$' \t']*}"}
5042
+ word=${first%%[$' \t']*}
5043
+ rest=${first#"$word"}
5044
+ rest=${rest#"${rest%%[!$' \t']*}"}
5045
+ handle=$(printf '%s' "$COMMAND_HANDLE" | tr '[:upper:]' '[:lower:]')
5046
+ if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" != "$handle" ]; then
5047
+ echo "remote-run.sh: control: ignored, the first line does not open with $COMMAND_HANDLE"
5048
+ return 1
5049
+ fi
5050
+ word=${rest%%[$' \t']*}
5051
+ CONTROL_VERB=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
5052
+ CONTROL_ARGS=${rest#"$word"}
5053
+ CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
5054
+ return 0
5055
+ }
5056
+
5057
+ verb_control() {
5058
+ local LC_ALL=C
5059
+ local review=0 forge="" target="" status verbs="" v
5060
+ case "${GITHUB_EVENT_NAME-}" in
5061
+ issue_comment) ;;
5062
+ pull_request_review) review=1 ;;
5063
+ *)
5064
+ echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment or pull_request_review, not '${GITHUB_EVENT_NAME-}'" >&2
5065
+ exit "$EXIT_USAGE" ;;
5066
+ esac
5067
+ if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
5068
+ echo "remote-run.sh: control: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
5069
+ exit "$EXIT_USAGE"
5070
+ fi
5071
+ hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
5072
+
5073
+ if [ "$review" -eq 1 ]; then
5074
+ control_review_intake || return 0
5075
+ else
5076
+ control_comment_intake || return 0
5077
+ fi
5078
+
5079
+ case "$CONTROL_NUMBER" in
5080
+ ''|*[!0-9]*|0*)
5081
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5082
+ exit "$EXIT_USAGE" ;;
5083
+ esac
5084
+
5085
+ control_tmp="${RUNNER_TEMP-}"
5086
+ if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5087
+ control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
5088
+ control_dirs="$control_tmp"
5089
+ fi
5090
+ trap control_cleanup EXIT
5091
+
5092
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5093
+ control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
5094
+ "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
5095
+ fi
5096
+
5097
+ forge=$(hr_forge "$root") || forge=""
5098
+ target=$(hr_execution_target "$root") || target=""
5099
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
5100
+ control_refuse "$EXIT_REFUSED" "the default branch's \`harness.config.json\` does not turn run control on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})" \
5101
+ "Set both keys on the default branch, then comment again."
5102
+ fi
5103
+
5104
+ status=0
5105
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5106
+ if [ "$status" -ne 0 ]; then
5107
+ control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
5108
+ "Only a collaborator with write, maintain or admin access, or a bot listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
5109
+ fi
5110
+
5111
+ if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
5112
+ for v in $COMMAND_VERBS; do
5113
+ [ "$v" != answer ] || v="answer [<n>]"
5114
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
5115
+ done
5116
+ control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" \
5117
+ "The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
5118
+ fi
5119
+
5120
+ forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`$CONTROL_VERB\` was not run: the repository's name could not be read."
5121
+ if [ "$review" -eq 1 ]; then
5122
+ control_check_branch "$REVIEW_HEAD"
5123
+ elif [ -n "$CONTROL_IS_PR" ]; then
5124
+ control_branch_from_pr "$CONTROL_NUMBER"
5125
+ else
5126
+ control_branch_from_issue "$CONTROL_NUMBER"
5127
+ fi
5128
+
5129
+ echo "remote-run.sh: control: $CONTROL_VERB on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
5130
+ case "$CONTROL_VERB" in
5131
+ answer) control_answer ;;
5132
+ pause) control_pause ;;
5133
+ stop) control_stop ;;
5134
+ resume) control_resume ;;
5135
+ clear) control_clear ;;
5136
+ review) control_review ;;
5137
+ esac
5138
+ }
5139
+
5140
+ # ---------------------------------------------------------------------------
5141
+ # `collect` — the run workflow's last job: the next round from the reviews
5142
+ # collected while the run was in flight. It reuses `control`'s settledness read,
5143
+ # child runner and cleanup, with CONTROL_BRANCH set to the branch.
5144
+ # ---------------------------------------------------------------------------
5145
+
5146
+ # collect_notify <text> — the one comment `collect` posts, on FORGE_PR with the
5147
+ # `reply` marker; a failure is one line.
5148
+ collect_notify() {
5149
+ local file
5150
+ if ! file=$(mktemp "$control_tmp/harness-collect-comment.XXXXXX"); then
5151
+ echo "remote-run.sh: collect: cannot create the comment file for #$FORGE_PR; no comment posted" >&2
5152
+ return 0
5153
+ fi
5154
+ {
5155
+ printf '%s\n' "$1"
5156
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
5157
+ } >"$file"
5158
+ forge_comment "$FORGE_PR" reply "$branch" "$file" || :
5159
+ rm -f "$file"
5160
+ }
5161
+
5162
+ verb_collect() {
5163
+ local status=0 dir file out
5164
+ if ! forge_on; then
5165
+ echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
5166
+ exit "$EXIT_OK"
5167
+ fi
5168
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5169
+ echo "remote-run.sh: collect: HARNESS_REMOTE_STOP is set; no round started"
5170
+ exit "$EXIT_OK"
5171
+ fi
5172
+ if ! hr_have_jq; then
5173
+ echo "remote-run.sh: collect: jq is missing; nothing collected"
5174
+ exit "$EXIT_OK"
5175
+ fi
5176
+ forge_repo_var || exit "$EXIT_OK"
5177
+
5178
+ remote_branch_stopped "$branch" || status=$?
5179
+ case "$status" in
5180
+ 0)
5181
+ echo "remote-run.sh: collect: $branch is stopped; no round started"
5182
+ exit "$EXIT_OK" ;;
5183
+ 2)
5184
+ echo "remote-run.sh: collect: whether $branch is stopped could not be read ($GH_ERR); no round started"
5185
+ exit "$EXIT_OK" ;;
5186
+ esac
5187
+
5188
+ if [ -n "$pr_arg" ]; then
5189
+ FORGE_PR="$pr_arg"
5190
+ elif ! forge_pr_var "$branch"; then
5191
+ echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
5192
+ exit "$EXIT_OK"
5193
+ fi
5194
+ if [ -z "$FORGE_PR" ]; then
5195
+ echo "remote-run.sh: collect: no open pull request; nothing to collect"
5196
+ exit "$EXIT_OK"
5197
+ fi
5198
+
5199
+ control_tmp="${RUNNER_TEMP-}"
5200
+ if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5201
+ control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
5202
+ control_dirs="$control_tmp"
5203
+ fi
5204
+ trap control_cleanup EXIT
5205
+
5206
+ CONTROL_BRANCH="$branch"
5207
+ if ! control_settled_var; then
5208
+ echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
5209
+ exit "$EXIT_OK"
5210
+ fi
5211
+ if [ "$BS_SETTLED" != 1 ]; then
5212
+ echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
5213
+ exit "$EXIT_OK"
5214
+ fi
5215
+
5216
+ forge_fetch_branch "$branch"
5217
+ if ! dir=$(mktemp -d "$control_tmp/harness-collect.XXXXXX"); then
5218
+ echo "::warning::remote-run.sh: collect: a round directory could not be created under '$control_tmp'; no round started"
5219
+ exit "$EXIT_OK"
5220
+ fi
5221
+ control_dirs="$control_dirs $dir"
5222
+ file="$dir/review.md"
5223
+ RC_EVENT=""
5224
+ status=0
5225
+ round_collect "$FORGE_PR" "$file" || status=$?
5226
+ case "$status" in
5227
+ 0) ;;
5228
+ 1)
5229
+ echo "remote-run.sh: collect: no review requesting changes is pending on #$FORGE_PR since the previous round of $branch; no round started"
5230
+ exit "$EXIT_OK" ;;
5231
+ *)
5232
+ echo "::warning::remote-run.sh: collect: the round of $branch could not be collected: $RC_ERR; no round started"
5233
+ exit "$EXIT_OK" ;;
5234
+ esac
5235
+
5236
+ out="$dir/review.out"
5237
+ set -- review "$branch" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
5238
+ case "$FORGE_SERVER" in
5239
+ https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR" ;;
5240
+ esac
5241
+ control_child "$out" "$@" --repo "$root"
5242
+ cat "$out" 2>/dev/null || :
5243
+ if [ "$CHILD_STATUS" -eq 0 ]; then
5244
+ echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
5245
+ exit "$EXIT_OK"
5246
+ fi
5247
+ 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."
5248
+ echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
5249
+ exit "$EXIT_OK"
5250
+ }
5251
+
5252
+ # ---------------------------------------------------------------------------
5253
+ # `discard` — remove a directory a command fetched into, inside scratch only.
5254
+ # ---------------------------------------------------------------------------
5255
+
5256
+ # The removal lives here rather than in the command because a supervised or
5257
+ # auto-mode session may refuse a recursive `rm` the agent types, and a
5258
+ # user-level `rm -rf` deny cannot be overridden (`.claude/context/
5259
+ # conventions.md` -> `## Shell assets`). The scope is the scratch directory
5260
+ # only, per the lessons ledger's rule that a script "never removes one it did
5261
+ # not create": scratch holds only throwaway files a session itself wrote. Containment is `hr_scratch_path_var`'s alone; this verb
5262
+ # maps its status and acts on `HR_SCRATCH_TARGET`. It never creates anything.
5263
+ verb_discard() {
5264
+ local status
5265
+ hr_scratch_path_var "$root" "$discard_dir" "$discard_base"
5266
+ status=$?
5267
+ case "$status:$HR_SCRATCH_WHY" in
5268
+ 0:*) ;;
5269
+ 1:dotdot)
5270
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries '..'" >&2
5271
+ exit "$EXIT_REFUSED" ;;
5272
+ 1:charset)
5273
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries a character a scratch path may not" >&2
5274
+ exit "$EXIT_REFUSED" ;;
5275
+ 1:itself)
5276
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is the scratch directory itself" >&2
5277
+ exit "$EXIT_REFUSED" ;;
5278
+ 1:symlink)
5279
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is a symlink" >&2
5280
+ exit "$EXIT_REFUSED" ;;
5281
+ 1:*)
5282
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not inside '$HR_SCRATCH_DIR/'" >&2
5283
+ exit "$EXIT_REFUSED" ;;
5284
+ 2:*)
5285
+ echo "remote-run.sh: discard: the parent directory of '$discard_dir' cannot be resolved; nothing removed" >&2
5286
+ exit "$EXIT_USAGE" ;;
5287
+ 3:no-scratch)
5288
+ echo "remote-run.sh: discard: the state directory's scratch/ under '$root' does not exist; nothing removed" >&2
5289
+ exit "$EXIT_USAGE" ;;
5290
+ 3:*)
5291
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
5292
+ exit "$EXIT_USAGE" ;;
5293
+ *)
5294
+ usage "discard needs a <dir>" ;;
5295
+ esac
5296
+ if [ ! -e "$HR_SCRATCH_TARGET" ]; then
5297
+ echo "remote-run.sh: $discard_dir does not exist; nothing removed"
5298
+ return 0
5299
+ fi
5300
+ if [ ! -d "$HR_SCRATCH_TARGET" ]; then
5301
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not a directory" >&2
5302
+ exit "$EXIT_REFUSED"
5303
+ fi
5304
+ if ! rm -rf -- "$HR_SCRATCH_TARGET" || [ -e "$HR_SCRATCH_TARGET" ]; then
5305
+ echo "remote-run.sh: discard: removing '$discard_dir' failed" >&2
5306
+ exit "$EXIT_USAGE"
5307
+ fi
5308
+ echo "remote-run.sh: removed $discard_dir"
5309
+ }
5310
+
1781
5311
  case "$verb" in
1782
5312
  dispatch) verb_dispatch ;;
1783
5313
  pause) verb_pause ;;
@@ -1791,5 +5321,15 @@ case "$verb" in
1791
5321
  poll) verb_poll ;;
1792
5322
  pause-requested) verb_pause_requested ;;
1793
5323
  run-created-at) verb_run_created_at ;;
5324
+ start) verb_start ;;
5325
+ fetch) verb_fetch ;;
5326
+ review) verb_review ;;
5327
+ trigger) verb_trigger ;;
5328
+ list) verb_list ;;
5329
+ discard) verb_discard ;;
5330
+ report) verb_report ;;
5331
+ deliver) verb_deliver ;;
5332
+ collect) verb_collect ;;
5333
+ control) verb_control ;;
1794
5334
  esac
1795
5335
  exit "$EXIT_OK"