autonomous-sdlc-harness 0.4.2 → 0.5.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.
@@ -4,8 +4,8 @@
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>..."]
@@ -15,31 +15,44 @@
15
15
  # remote-run.sh stop <branch> [--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> [--repo <root>]
27
+ # remote-run.sh trigger [--repo <root>] (its own exit map: its paragraph)
28
+ # remote-run.sh list [--repo <root>]
24
29
  # 0 sent (for stop: the action=stop marker was dispatched, and every
25
30
  # 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
31
+ # asked to cancel, or there was none); for status and fetch: printed
32
+ # (for fetch, `state: none` included); for sync: the record is
27
33
  # current (including "no run listed yet", which writes nothing); for
28
34
  # restore: restored, or no previous bundle (or an expired one, with a
29
35
  # `::warning::` line) under --resume none|pause;
30
36
  # for save: ALWAYS, whatever happened; for continue: whatever it
31
37
  # decided — every outcome a person must act on is a notification; for
32
38
  # poll: the tick finished; for pause-requested: such a run exists; for
33
- # run-created-at: printed
39
+ # run-created-at: printed; for review: placed, pushed and dispatched;
40
+ # for list: printed
34
41
  # 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
42
+ # resolved; for fetch, <out_dir> is not an existing, empty directory;
43
+ # for sync and restore, a local copy or write failed; for
36
44
  # pause-requested, also NO such run — a caller that reads 1 as "no
37
45
  # pause" passes arguments it has already validated
38
46
  # 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
47
+ # github-actions (sending verbs, fetch, review and list); for
48
+ # review, a protected branch, a review file that is not a readable
49
+ # regular file, or a run in flight (its paragraph); for sync, the
50
+ # branch's local record does not carry `execution: github-actions`; for
51
+ # status, a local record that does not carry `execution:
52
+ # github-actions`, or no record and `execution.target` not
53
+ # `github-actions`; the record's mirror working copy is missing, or a
54
+ # downloaded bundle is unrecognised (sync, restore, and status with no
55
+ # local record); the inputs payload is over the limit; a named answer
43
56
  # file is missing. For restore under --resume answer, "nothing more":
44
57
  # no previous bundle, the previous bundle expired (the message names
45
58
  # its expiry and the resume command), `HARNESS_INPUT_ANSWERS` not an object of
@@ -49,7 +62,142 @@
49
62
  # 3 gh failed: not found, or a non-zero exit — the first line of gh's
50
63
  # stderr is named. For poll: the listing or the disable failed. For
51
64
  # pause-requested and run-created-at, also an answer that is not the
52
- # expected JSON; a caller never pauses on a failed read
65
+ # expected JSON; a caller never pauses on a failed read. For start, the
66
+ # dispatch failed AFTER the branch and its task prompt were pushed; for
67
+ # review, the listing failed, or the dispatch failed AFTER the review
68
+ # was pushed. For
69
+ # list, the listing or `git ls-remote` failed; nothing written
70
+ # 4 start: placement failed — the branch cut, the copy, the commit or the
71
+ # push — and nothing was dispatched; the working copy and the local
72
+ # branch the cut created were removed. review: placement failed — the
73
+ # copy (the branch checked out in another working copy included), the
74
+ # fast-forward, the commit or the push — and nothing was dispatched; a
75
+ # copy it cut was removed
76
+ #
77
+ # `start` IS THE ADAPTERS' ONE ENTRY: every trigger (an issue event, a forge
78
+ # dispatch, anything later) reduces to a branch and a task text and ends here.
79
+ # In order, stopping at the first failure: refuse a protected branch (2); refuse
80
+ # a prompt file that is not a readable regular file (2); cut the branch from
81
+ # `origin/<defaultBranch>` with `create-worktree.sh --no-bootstrap`; place the
82
+ # file at `<state_dir>/task_prompts/<branch>_task_prompt.md` in that working
83
+ # copy (the state directory resolved there, never in the main checkout), commit
84
+ # it as `chore: add task prompt for <branch>` and confirm `origin/<branch>`
85
+ # equals `HEAD` (each failure 4); then remove the working copy and the local
86
+ # branch it created — on every exit after the cut, success included and
87
+ # wherever `start` runs, because nothing reads the copy once the push has landed
88
+ # and a leftover branch makes the next cut of that name refuse; a copy or branch
89
+ # that existed before the cut is never removed — and `dispatch --engine task
90
+ # --resume none --chain 0`, composed by `verb_dispatch` itself. The placement is the library's
91
+ # (`hr_task_prompt_rel`, `hr_place_artifact`, `hr_commit_placed`,
92
+ # `hr_push_landed`), the same calls the watcher's inbox pass makes, so nothing
93
+ # downstream can tell where a task came from. It writes no registry record: a
94
+ # trigger job has no registry, and such a run needs no local record: the local
95
+ # commands act on it through GitHub.
96
+ #
97
+ # `fetch` IS THE COMMANDS' READ OF ONE BRANCH ON GITHUB, needing no local
98
+ # record. Gated like a sending verb. <out_dir> must be an existing, empty
99
+ # directory. It reads the newest `harness run <branch>` run through the same
100
+ # derivation `sync` makes (`remote_state`), downloads that run's state bundle
101
+ # into <out_dir> when one applies, and prints these lines, each always present
102
+ # and empty when unknown — the key names are a wire the commands parse:
103
+ # run_id: run_url: run_status: the newest `harness run <branch>` run
104
+ # state: `none` when no such run is listed, else the derivation's
105
+ # pause_reason: engine: detail: from the derivation
106
+ # open_questions: space-separated <n> of every top-level
107
+ # `<out_dir>/clarifications/<branch>/question_<n>.md` with
108
+ # no `answer_<n>.md` beside it, ascending
109
+ # bundle_dir: <out_dir> when a bundle was downloaded
110
+ #
111
+ # `review` PLACES A USER REVIEW ROUND ON THE BRANCH TIP AND DISPATCHES IT, for
112
+ # `/autonomous-sdlc-harness:branch-user-review` on a run that executes on
113
+ # GitHub. In order, stopping at the first failure: refuse a protected branch
114
+ # and a review file that is not a readable regular file (2; a relative
115
+ # --review-file resolves against the caller's directory); refuse a branch whose
116
+ # newest `harness run <branch>` run, by `remote_state`, is anything but
117
+ # `completed` or `failed` — none listed, `running`, `parked`, `park_loop` or
118
+ # `paused`, an expired bundle naming its expiry (2); the bundle it reads is
119
+ # downloaded to `sync`'s directory, the one write a refusal makes. The copy: the main
120
+ # checkout's remote record's mirror when its `worktree` exists and is on the
121
+ # branch, never removed; otherwise `create-worktree.sh --existing
122
+ # --no-bootstrap` into `hr_worktree_dir`, removed with the local branch it
123
+ # DWIM-created on every exit, as `start` removes its cut. Either copy is
124
+ # fast-forwarded to `origin/<branch>`. No bootstrap runs: the copy holds one
125
+ # placed file. The round comes from `git ls-tree` of the copy's `HEAD` under
126
+ # `<state_dir>/user_reviews/` — the engine's own round source: each basename
127
+ # matching `^(.+)_review(_[0-9]+)?\.md$` whose captured branch EQUALS <branch>,
128
+ # the unsuffixed file being round 1; next is `<branch>_review.md` when none
129
+ # matched, else `<branch>_review_<max+1>.md`. It is placed, committed as
130
+ # `hr_user_review_subject`'s `chore: add user review for <branch>` and pushed
131
+ # (each failure 4); the cut copy is removed; then `dispatch --engine
132
+ # user_review --resume none --chain 0`. A remote record, when one exists, is
133
+ # set `running` / `user_review` in one write after the dispatch.
134
+ #
135
+ # `trigger` IS THE GITHUB EVENT ADAPTER, the one step of the trigger
136
+ # workflow's job: event -> (branch, task text) -> `start`. It handles
137
+ # `GITHUB_EVENT_NAME` `issues` and `repository_dispatch`; any other name, or an
138
+ # event file it cannot read, exits 1. Like `restore` it acts on `hr_repo_root` of the working
139
+ # directory, and it takes no part in the sending-verb gate: it gates itself, so
140
+ # a refusal can still be commented. It reads, only from the environment:
141
+ # GITHUB_EVENT_NAME, GITHUB_EVENT_PATH the event; each field is read by `jq`
142
+ # into a variable and is only ever an argument or file
143
+ # bytes, never shell source
144
+ # GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
145
+ # the URLs its comments name
146
+ # HARNESS_REMOTE_STOP non-empty: every start is refused
147
+ # HARNESS_TRIGGER_LABEL the trigger label; `DEFAULT_TRIGGER_LABEL` when empty
148
+ # HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
149
+ # HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
150
+ # A test seam
151
+ # RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
152
+ # directory when empty
153
+ # An `action` other than `labeled`, or another label, is one line and exit 0
154
+ # with no `gh` call. Otherwise refused, in this order, each refusal one issue
155
+ # comment naming the reason and the way on:
156
+ # 1. `HARNESS_REMOTE_STOP` is set
157
+ # 2. `hr_forge` is not `github` or `hr_execution_target` is not
158
+ # `github-actions` — before any authorisation, so a disabled trigger asks
159
+ # GitHub nothing about the labeller
160
+ # 3. the issue is not `open`
161
+ # 4. `sender.login` is `ghost` (GitHub's placeholder for a deleted account),
162
+ # empty, or not a login shape (`^[A-Za-z0-9][A-Za-z0-9-]*$`, plus `[bot]`
163
+ # for a `Bot`)
164
+ # 5. `sender.type` is not `User` and the login is not an exact entry of
165
+ # `HARNESS_TRIGGER_ALLOWED_BOTS` — checked by the listing alone, with no
166
+ # permission call, because the permission API answers `none` or 404 for a bot
167
+ # 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
168
+ # `write` — `maintain` reads as `write` and `triage` as `read` there; a
169
+ # failed call is "could not confirm write access", never a pass
170
+ # Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
171
+ # branch with `hr_derive_branch <title> issue_<number>` (2 or 3 refused), writes
172
+ # the snapshot — `# <title>`, the body's bytes, `---` and a provenance sentence
173
+ # naming the issue, the labeller, the label and the time — and runs `start` as a
174
+ # child. After a start it looks up the `harness run <branch>` run, at most
175
+ # `TRIGGER_RUN_LOOKUP_TRIES` times, falling back to the branch's filtered run
176
+ # list, and comments the branch and that URL. Every comment is followed by
177
+ # removing the label, so re-applying it is deliberate; a removal that fails is
178
+ # one `::warning::` line.
179
+ # A `repository_dispatch` reads `.action` (where GitHub puts the `event_type`)
180
+ # and `client_payload`'s `title`, `body` and `source`, the contract being
181
+ # {"event_type": TRIGGER_DISPATCH_EVENT_TYPE, "client_payload": {"title": …,
182
+ # "body": …, "source": …}}
183
+ # within GitHub's `client_payload` limits: at most 10 top-level properties and
184
+ # under 64 KB, so a longer task text does not fit. Another `.action` is one line
185
+ # and exit 0 with no `gh` call; an empty or missing `title` is refused. It has
186
+ # no labeller: GitHub sends one only for a fine-grained token with Contents
187
+ # write or a classic token with `repo`, so the token holder is the authority,
188
+ # and refusals 3 to 6 do not apply. The fallback name is `task_<GITHUB_RUN_ID>`
189
+ # and the snapshot's provenance sentence names the event type, `source` when
190
+ # set, and the time. There is no issue, so no comment and no label: every
191
+ # outcome is printed to stdout and appended as a Markdown block to
192
+ # `GITHUB_STEP_SUMMARY` when that is set, an append failure one `::warning::`.
193
+ # 0 started (commented), or ignored
194
+ # 1 not an `issues` or `repository_dispatch` event, or the event could not
195
+ # be read
196
+ # 2 refused (commented)
197
+ # 3 a `gh` step after the decision failed: the comment could not be posted
198
+ # (an `::error::` line; issues only), or `start` pushed the branch but
199
+ # its dispatch failed (commented with the manual Run-workflow way on)
200
+ # 4 `start` refused or failed its placement (commented)
53
201
  #
54
202
  # `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
55
203
  # its job, before and (under `always()`) after the harness step. Without
@@ -204,11 +352,28 @@
204
352
  # `gh run view <run_id> --json createdAt` as an epoch second.
205
353
  #
206
354
  # `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`.
355
+ # it started with, so where a record exists they test its `execution` field and
356
+ # never `execution.target`; the configuration is still read for `stateDir`.
357
+ # Only `status` with no record reads the key.
358
+ #
359
+ # `status` WITH NO LOCAL RECORD (no registry file, or no record of the branch)
360
+ # is gated like a sending verb and answers from GitHub alone: the same runs
361
+ # listing, then `remote_state` with the bundle downloaded into a `mktemp -d`
362
+ # directory removed on exit, printing the state, pause reason, detail, engine
363
+ # and run URL, each open question (the rule `fetch` states) with its `## Q<k>`
364
+ # heading lines; an expired bundle's expired line is its `detail`. No `harness run
365
+ # <branch>` run listed prints one line and exits 0. There is no sync to compare
366
+ # against, so no finished-since line.
209
367
  #
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
368
+ # `list` IS `branch-status`'s DIGEST: one listing (`list_all_runs`) and one
369
+ # `git ls-remote --heads origin`, never a bundle. It prints `on GitHub, no
370
+ # local record: <branch> <url>` for each branch `unrecorded_runs` keeps — its
371
+ # newest run titled exactly `harness run <branch>`, no registry record,
372
+ # unprotected and a live head on origin — or `no run on GitHub without a local
373
+ # record`.
374
+ #
375
+ # `status` WITH A RECORD WRITES NOTHING AT ALL — no registry (it does not even
376
+ # create an absent one), no download, no file. It prints the branch's newest runs titled
212
377
  # `harness run <branch>` or `harness pause <branch>` (bounded), the record's
213
378
  # `status`, `pause_reason`, `remote_run_url` and `remote_synced_at`, whether
214
379
  # a `harness run` finished after the last sync, and — reading the newest
@@ -234,7 +399,8 @@
234
399
  # `mirror` mode into the record's `worktree`, `run.log` copied to the main
235
400
  # checkout's `autonomous_logs/<branch>.remote.log`, and `status`,
236
401
  # `pause_reason`, `usage_resume_at`, `park_loop_cycles`, `remote_run_id`,
237
- # `remote_run_url`, `remote_detail` and `remote_synced_at` written. A
402
+ # `remote_run_url`, `remote_detail` and `remote_synced_at` written, and
403
+ # `engine` when the bundle names `task`, `user_review` or `docs`. A
238
404
  # `mirror` restore places no planning draft
239
405
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
240
406
  # older finished run carries one): a job that died before its upload.
@@ -294,7 +460,18 @@
294
460
  # root's `harness.config.json`.
295
461
  #
296
462
  # 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
463
+ # inbox, and never watches a run it sent. Only `start` and `review` push, and
464
+ # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
465
+ # the prompt committed on `origin/<branch>`, through a working copy and a local
466
+ # branch it removes before it returns; `review`'s are the round committed on
467
+ # `origin/<branch>`, through the record's mirror or a copy and a local branch
468
+ # it removes, the record's `status` / `engine`, and the bundle download
469
+ # directory `sync` uses. A user's chain-0 `dispatch --resume answer|pause`
470
+ # writes the record's `status`, `resumed_at` and `resume_kind` in one write
471
+ # when the main checkout's registry file exists and holds a record with
472
+ # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
473
+ # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
474
+ # for a dispatch event a block in the step summary. Every other verb's only writes are the
298
475
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
299
476
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
300
477
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
@@ -304,7 +481,9 @@
304
481
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
305
482
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
306
483
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
307
- # and `current/`. `pause-requested` and `run-created-at` write nothing.
484
+ # and `current/`. For `fetch`, <out_dir> only. `pause-requested`,
485
+ # `run-created-at`, `list` and `status` write nothing; a no-record `status`
486
+ # downloads into a temporary directory it removes on exit.
308
487
  #
309
488
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
310
489
  # rename there is an edit here, byte for byte:
@@ -313,6 +492,10 @@
313
492
  # STATE_ARTIFACT_NAME mirrors STATE_ARTIFACT_NAME
314
493
  # POLL_STATE_ARTIFACT_NAME mirrors POLL_STATE_ARTIFACT_NAME
315
494
  # HARNESS_GH_CLI mirrors GH_CLI_VARIABLE (the binary run as `gh`)
495
+ # HARNESS_TRIGGER_LABEL mirrors TRIGGER_LABEL_VARIABLE
496
+ # DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
497
+ # HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
498
+ # TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
316
499
  #
317
500
  # `set -u` WITHOUT `-e`: every refusal is reported with its own exit code rather
318
501
  # than aborting mid-decision.
@@ -345,6 +528,37 @@
345
528
  # bash scripts/remote-run.sh pause feat_x -> 3, names `boom`
346
529
  # gh absent HARNESS_GH_CLI=/nonexistent bash scripts/remote-run.sh warm -> 3
347
530
  #
531
+ # start needs the adopted tree on origin's default branch (commit and push
532
+ # it first) and a prompt file outside the checkout, say /tmp/p.md:
533
+ # start bash scripts/remote-run.sh start feat_x --prompt-file /tmp/p.md
534
+ # -> 0; origin/feat_x gains `chore: add task prompt for feat_x`,
535
+ # then "$s.log" gains the same `workflow run` line as dispatch
536
+ # protected bash scripts/remote-run.sh start main --prompt-file /tmp/p.md
537
+ # -> 2, log unchanged, nothing pushed
538
+ # no prompt bash scripts/remote-run.sh start feat_y --prompt-file /nonexistent
539
+ # -> 2, log unchanged, nothing pushed
540
+ # review after that start, a `run list` answer whose newest `harness run
541
+ # feat_x` run is `completed` with a bundle saying `completed`:
542
+ # bash scripts/remote-run.sh review feat_x --review-file /tmp/r.md
543
+ # -> 0; origin/feat_x gains `chore: add user review for feat_x`
544
+ # placing feat_x_review.md, then one `-f engine=user_review`
545
+ # dispatch; no copy or local feat_x is left
546
+ # in flight the newest run `in_progress` -> 2, nothing pushed or sent
547
+ # fetch t=$(mktemp -d); bash scripts/remote-run.sh fetch feat_x "$t"
548
+ # -> 0; prints `state: running` (or, with no run listed,
549
+ # `state: none`), every other key present
550
+ #
551
+ # no record with no registry, the "a bundle" stub below with question_1.md:
552
+ # bash scripts/remote-run.sh status feat_x -> 0; prints `state:
553
+ # parked` and `open question question_1.md`; nothing written
554
+ # list start's setup, a feat_x pushed to origin, no registry, and a
555
+ # `run list` answer carrying `headBranch` feat_x, `displayTitle`
556
+ # `harness run feat_x` and a `url`: bash scripts/remote-run.sh
557
+ # list -> 0; prints `on GitHub, no local record: feat_x <url>`;
558
+ # no registry is created
559
+ # adopt bash scripts/remote-run.sh adopt -> 1, an unknown verb; no gh
560
+ # call, no registry
561
+ #
348
562
  # status and sync need a remote record, and a `run list` answer whose runs
349
563
  # carry `displayTitle` `harness run feat_x` and a `url`:
350
564
  # r=sdlc-harness/autonomous_logs/registry.json; mkdir -p "${r%/*}"
@@ -432,6 +646,26 @@
432
646
  # run-created-at a stub answering `run view 42 --json createdAt` with
433
647
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
434
648
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
649
+ #
650
+ # trigger needs start's setup plus `"forge": "github"`, an event file e.json
651
+ # {"action":"labeled","label":{"name":"harness"},"sender":{"login":"alice",
652
+ # "type":"User"},"issue":{"number":7,"title":"Add comments","body":"x",
653
+ # "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
654
+ # answering `api repos/o/r/collaborators/alice/permission` with
655
+ # {"permission":"write"}; export GITHUB_EVENT_NAME=issues GITHUB_EVENT_PATH=e.json
656
+ # GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0:
657
+ # trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
658
+ # the prompt commit, "$s.log" gains `workflow run harness-run.yml
659
+ # --ref add_comments ...`, `issue comment 7 ...` naming the branch,
660
+ # then `issue edit 7 ... --remove-label harness`
661
+ # read the permission answer {"permission":"read"} -> 2, no `workflow
662
+ # run`, one comment naming write access, the label removed
663
+ # ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
664
+ # dispatch GITHUB_EVENT_NAME=repository_dispatch GITHUB_RUN_ID=9
665
+ # GITHUB_STEP_SUMMARY=/tmp/s, e.json {"action":<TRIGGER_DISPATCH_EVENT_TYPE>,
666
+ # "client_payload":{"title":"Add tags","body":"x","source":"jira"}}
667
+ # -> 0; `workflow run ... --ref add_tags ...`, /tmp/s names
668
+ # add_tags, no `issue` call; without "title" -> 2, no `workflow run`
435
669
 
436
670
  set -u
437
671
 
@@ -448,6 +682,8 @@ WORKFLOW_RUN_FILE='harness-run.yml'
448
682
  WORKFLOW_RESUME_FILE='harness-resume.yml'
449
683
  STATE_ARTIFACT_NAME='harness-state'
450
684
  POLL_STATE_ARTIFACT_NAME='harness-poll-state'
685
+ DEFAULT_TRIGGER_LABEL='harness'
686
+ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
451
687
  GH="${HARNESS_GH_CLI:-gh}"
452
688
 
453
689
  # How many runs `status` prints, and how many `run list` returns for status
@@ -468,6 +704,10 @@ POLL_GIVE_UP_AFTER_MINUTES_DEFAULT=360
468
704
  # How many of the poller's own runs `poll` searches for the previous tick's
469
705
  # state artifact; each one without it costs an artifact lookup.
470
706
  POLL_STATE_RUNS_LIMIT=10
707
+ # `trigger`'s bound on looking up the run its `start` dispatched; the wait
708
+ # between tries is `HARNESS_TRIGGER_LOOKUP_SECS`, a test seam.
709
+ TRIGGER_RUN_LOOKUP_TRIES=6
710
+ TRIGGER_LOOKUP_SECS_DEFAULT=5
471
711
 
472
712
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
473
713
  # maximum payload for inputs is 65,535 characters."
@@ -478,6 +718,7 @@ EXIT_OK=0
478
718
  EXIT_USAGE=1
479
719
  EXIT_REFUSED=2
480
720
  EXIT_GH=3
721
+ EXIT_PLACEMENT=4
481
722
  # pause-requested only: the read succeeded and found no pause.
482
723
  EXIT_NO_PAUSE=1
483
724
 
@@ -489,12 +730,17 @@ usage() {
489
730
  echo " remote-run.sh stop <branch> [--repo <root>]" >&2
490
731
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
491
732
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
733
+ echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
492
734
  echo " remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]" >&2
493
735
  echo " remote-run.sh save <branch> <out_dir> [--repo <root>]" >&2
494
736
  echo " remote-run.sh continue <branch> <bundle_dir> [--repo <root>]" >&2
495
737
  echo " remote-run.sh poll [--repo <root>]" >&2
496
738
  echo " remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]" >&2
497
739
  echo " remote-run.sh run-created-at <run_id> [--repo <root>]" >&2
740
+ echo " remote-run.sh start <branch> --prompt-file <file> [--repo <root>]" >&2
741
+ echo " remote-run.sh review <branch> --review-file <file> [--repo <root>]" >&2
742
+ echo " remote-run.sh trigger [--repo <root>]" >&2
743
+ echo " remote-run.sh list [--repo <root>]" >&2
498
744
  [ "${verb-}" != save ] || exit "$EXIT_OK"
499
745
  exit "$EXIT_USAGE"
500
746
  }
@@ -545,7 +791,7 @@ verb=""
545
791
  verb="$1"
546
792
  shift
547
793
  case "$verb" in
548
- dispatch|pause|warm|stop|status|sync|restore|save|continue|poll|pause-requested|run-created-at) ;;
794
+ dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list) ;;
549
795
  *) usage "unknown verb '$verb'" ;;
550
796
  esac
551
797
 
@@ -563,6 +809,8 @@ chain="0"
563
809
  repo_arg=""
564
810
  since_arg=""
565
811
  run_id_arg=""
812
+ prompt_file=""
813
+ review_file=""
566
814
 
567
815
  while [ "$#" -gt 0 ]; do
568
816
  case "$1" in
@@ -586,10 +834,19 @@ while [ "$#" -gt 0 ]; do
586
834
  --park-loop-clear)
587
835
  [ "$verb" = dispatch ] || usage "$1 is a dispatch option"
588
836
  park_loop_clear=1; shift ;;
837
+ --prompt-file)
838
+ [ "$verb" = start ] || usage "$1 is a start option"
839
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
840
+ prompt_file="$2"; shift 2 ;;
841
+ --review-file)
842
+ [ "$verb" = review ] || usage "$1 is a review option"
843
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
844
+ review_file="$2"; shift 2 ;;
589
845
  -*)
590
846
  usage "unknown option '$1'" ;;
591
847
  *)
592
- [ "$verb" != warm ] && [ "$verb" != poll ] || usage "$verb takes no branch"
848
+ [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != trigger ] \
849
+ && [ "$verb" != list ] || usage "$verb takes no branch"
593
850
  if [ "$verb" = run-created-at ]; then
594
851
  [ -z "$run_id_arg" ] || usage "unexpected argument '$1'"
595
852
  run_id_arg="$1"
@@ -597,7 +854,7 @@ while [ "$#" -gt 0 ]; do
597
854
  branch="$1"
598
855
  elif [ "$verb" = pause-requested ] && [ -z "$since_arg" ]; then
599
856
  since_arg="$1"
600
- elif [ "$verb" = save ] && [ -z "$out_dir" ]; then
857
+ elif { [ "$verb" = save ] || [ "$verb" = fetch ]; } && [ -z "$out_dir" ]; then
601
858
  out_dir="$1"
602
859
  elif [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
603
860
  bundle_dir="$1"
@@ -608,7 +865,8 @@ while [ "$#" -gt 0 ]; do
608
865
  esac
609
866
  done
610
867
 
611
- if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ]; then
868
+ if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ] && [ "$verb" != trigger ] \
869
+ && [ "$verb" != list ]; then
612
870
  valid_branch "$branch" || usage "$verb needs a <branch>"
613
871
  fi
614
872
 
@@ -634,6 +892,18 @@ if [ "$verb" = save ] && [ -z "$out_dir" ]; then
634
892
  usage "save needs an <out_dir>"
635
893
  fi
636
894
 
895
+ if [ "$verb" = fetch ] && [ -z "$out_dir" ]; then
896
+ usage "fetch needs an <out_dir>"
897
+ fi
898
+
899
+ if [ "$verb" = review ] && [ -z "$review_file" ]; then
900
+ usage "review needs --review-file"
901
+ fi
902
+
903
+ if [ "$verb" = start ] && [ -z "$prompt_file" ]; then
904
+ usage "start needs --prompt-file"
905
+ fi
906
+
637
907
  if [ "$verb" = restore ]; then
638
908
  [ "$resume_given" -eq 1 ] || usage "restore needs --resume"
639
909
  case "$resume" in
@@ -683,7 +953,7 @@ setup_fail() {
683
953
  if [ -n "$repo_arg" ]; then
684
954
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
685
955
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
686
- || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ]; then
956
+ || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ]; then
687
957
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
688
958
  else
689
959
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -691,6 +961,7 @@ fi
691
961
 
692
962
  hr_config_load "$root" || :
693
963
  registry=""
964
+ status_no_record=0
694
965
  case "$verb" in
695
966
  restore|save|continue|poll)
696
967
  hr_state_path "$root" >/dev/null || setup_fail "cannot resolve '$root/harness.config.json'"
@@ -698,6 +969,9 @@ case "$verb" in
698
969
  pause-requested|run-created-at)
699
970
  # Read verbs: no gate, and nothing of the configuration is read.
700
971
  ;;
972
+ trigger)
973
+ # Gates itself, after reading the event, so a refusal can still be commented.
974
+ ;;
701
975
  status|sync)
702
976
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || {
703
977
  echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
@@ -705,7 +979,18 @@ case "$verb" in
705
979
  }
706
980
  # Tested with -f first: hr_registry_get creates an absent registry, and
707
981
  # status writes nothing.
708
- if [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch" execution)" != github-actions ]; then
982
+ if [ "$verb" = status ] && { [ ! -f "$registry" ] || [ -z "$(hr_registry_get "$registry" "$branch" branch)" ]; }; then
983
+ # No record: GitHub alone answers, behind the sending verbs' gate.
984
+ status_no_record=1
985
+ target=$(hr_execution_target "$root") || {
986
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json' (or execution.target is outside its enum)" >&2
987
+ exit "$EXIT_USAGE"
988
+ }
989
+ if [ "$target" != github-actions ]; then
990
+ echo "remote-run.sh: refused, nothing sent: execution.target is '$target', not github-actions" >&2
991
+ exit "$EXIT_REFUSED"
992
+ fi
993
+ elif [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch" execution)" != github-actions ]; then
709
994
  echo "remote-run.sh: refused, nothing written: the local record of '$branch' does not carry execution: github-actions" >&2
710
995
  exit "$EXIT_REFUSED"
711
996
  fi
@@ -727,6 +1012,20 @@ case "$bundle_dir" in
727
1012
  ''|/*) ;;
728
1013
  *) bundle_dir="${PWD-.}/$bundle_dir" ;;
729
1014
  esac
1015
+ case "$prompt_file" in
1016
+ ''|/*) ;;
1017
+ *) prompt_file="${PWD-.}/$prompt_file" ;;
1018
+ esac
1019
+ case "$review_file" in
1020
+ ''|/*) ;;
1021
+ *) review_file="${PWD-.}/$review_file" ;;
1022
+ esac
1023
+ if [ "$verb" = fetch ]; then
1024
+ case "$out_dir" in
1025
+ /*) ;;
1026
+ *) out_dir="${PWD-.}/$out_dir" ;;
1027
+ esac
1028
+ fi
730
1029
 
731
1030
  cd "$root" || setup_fail "cannot enter '$root'"
732
1031
 
@@ -734,6 +1033,9 @@ cd "$root" || setup_fail "cannot enter '$root'"
734
1033
  # The verbs.
735
1034
  # ---------------------------------------------------------------------------
736
1035
 
1036
+ # Appended to a failed dispatch's message; `start` sets it once its branch is pushed.
1037
+ dispatch_fail_note=""
1038
+
737
1039
  verb_dispatch() {
738
1040
  local answers="" value n file payload
739
1041
  local -a inputs
@@ -770,8 +1072,19 @@ verb_dispatch() {
770
1072
  exit "$EXIT_REFUSED"
771
1073
  fi
772
1074
 
773
- gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed"
1075
+ gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed$dispatch_fail_note"
774
1076
  echo "remote-run.sh: dispatched action=run engine=$engine resume=$resume for $branch"
1077
+
1078
+ # A user's resume: the main checkout's remote record, when one exists, is
1079
+ # running now.
1080
+ if [ "$((10#$chain))" -eq 0 ] && { [ "$resume" = answer ] || [ "$resume" = pause ]; }; then
1081
+ local reg
1082
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
1083
+ if remote_record_exists "$reg"; then
1084
+ hr_registry_set "$reg" "$branch" status running resumed_at "$(date '+%Y-%m-%dT%H:%M:%S')" resume_kind "$resume" \
1085
+ || echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
1086
+ fi
1087
+ fi
775
1088
  }
776
1089
 
777
1090
  verb_pause() {
@@ -921,6 +1234,55 @@ set_many_or_fail() {
921
1234
  }
922
1235
  }
923
1236
 
1237
+ # open_questions_in <bundle_dir> — OPEN_QUESTIONS: the space-separated <n>,
1238
+ # ascending, of every top-level `clarifications/<branch>/question_<n>.md` in the
1239
+ # bundle with no `answer_<n>.md` beside it.
1240
+ OPEN_QUESTIONS=""
1241
+ open_questions_in() {
1242
+ local clar f n
1243
+ OPEN_QUESTIONS=""
1244
+ hr_remote_names_var
1245
+ clar="$1/$HR_REMOTE_CLARIFY_DIR/$branch"
1246
+ for f in "$clar"/question_*.md; do
1247
+ [ -f "$f" ] || continue
1248
+ n="${f##*/question_}"
1249
+ n="${n%.md}"
1250
+ [[ "$n" =~ ^[0-9]+$ ]] || continue
1251
+ [ -e "$clar/answer_$n.md" ] || OPEN_QUESTIONS="$OPEN_QUESTIONS $n"
1252
+ done
1253
+ if [ -n "$OPEN_QUESTIONS" ]; then
1254
+ OPEN_QUESTIONS=$(printf '%s\n' $OPEN_QUESTIONS | sort -n | tr '\n' ' ')
1255
+ OPEN_QUESTIONS="${OPEN_QUESTIONS% }"
1256
+ fi
1257
+ }
1258
+
1259
+ # status_from_github — `status` with no local record: the newest run's state
1260
+ # read through `remote_state` into a temporary directory removed on exit.
1261
+ status_tmp=""
1262
+ status_from_github() {
1263
+ local n
1264
+ if [ -z "$(titled_runs "harness run $branch" | jq -c '.[0] // empty')" ]; then
1265
+ echo "remote-run.sh: no local record, and no run titled 'harness run $branch' on GitHub"
1266
+ return 0
1267
+ fi
1268
+ echo "remote-run.sh: no local record; state from GitHub (newest run):"
1269
+ status_tmp=$(mktemp -d) || { echo "remote-run.sh: cannot create a temporary directory" >&2; exit "$EXIT_USAGE"; }
1270
+ trap 'rm -rf "$status_tmp"' EXIT
1271
+ remote_state "$status_tmp"
1272
+ printf ' state: %s\n' "$RS_STATE"
1273
+ printf ' pause_reason: %s\n' "$RS_PAUSE_REASON"
1274
+ printf ' detail: %s\n' "$RS_DETAIL"
1275
+ printf ' engine: %s\n' "$RS_ENGINE"
1276
+ printf ' run_url: %s\n' "$RS_RUN_URL"
1277
+ if [ "$RS_BUNDLE" -eq 1 ]; then
1278
+ open_questions_in "$status_tmp"
1279
+ for n in $OPEN_QUESTIONS; do
1280
+ echo "remote-run.sh: open question question_$n.md"
1281
+ grep -E '^## Q[0-9]+' "$status_tmp/$HR_REMOTE_CLARIFY_DIR/$branch/question_$n.md" | sed 's/^/ /'
1282
+ done
1283
+ fi
1284
+ }
1285
+
924
1286
  verb_status() {
925
1287
  local runs finished_id synced_id field
926
1288
  list_runs
@@ -929,6 +1291,10 @@ verb_status() {
929
1291
  printf '%s' "$runs" | jq -r --argjson n "$STATUS_RUNS_SHOWN" '
930
1292
  if length == 0 then " (none)" else
931
1293
  .[:$n][] | " \(.databaseId) \(.displayTitle) \(.status)/\(.conclusion // "") \(.createdAt) \(.url)" end'
1294
+ if [ "$status_no_record" -eq 1 ]; then
1295
+ status_from_github
1296
+ return 0
1297
+ fi
932
1298
  echo "remote-run.sh: local record (last synced):"
933
1299
  for field in status pause_reason remote_run_url remote_synced_at; do
934
1300
  printf ' %s: %s\n' "$field" "$(hr_registry_get "$registry" "$branch" "$field")"
@@ -957,78 +1323,171 @@ sync_expired() {
957
1323
  echo "remote-run.sh: $line"
958
1324
  }
959
1325
 
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')
1326
+ # remote_state <download_dir> [<applied_run_id>] — the one derivation of a
1327
+ # branch's newest remote state, from list_runs' answer in GH_OUT; `sync`,
1328
+ # `fetch`, `review` and `status` with no local record all call it. RS_STATE is `none` (no `harness run
1329
+ # <branch>` run listed), `running` (the newest is not `completed`), `applied`
1330
+ # (its id is <applied_run_id>: `sync`'s case 1, decided there), or the state
1331
+ # of `sync`'s cases 2-5, which it derives in that order. A bundle is downloaded
1332
+ # into <download_dir> — `sync`'s per-run directory under the main checkout when
1333
+ # empty — skipped when that directory already holds its status.json, and
1334
+ # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
1335
+ # existing for case 4. Exits 3 when gh fails, 2 for an unrecognised bundle.
1336
+ RS_RUNS=""
1337
+ RS_RUN_ID=""
1338
+ RS_RUN_URL=""
1339
+ RS_GH_STATUS=""
1340
+ RS_STATE=""
1341
+ RS_PAUSE_REASON=""
1342
+ RS_DETAIL=""
1343
+ RS_ENGINE=""
1344
+ RS_USAGE_RESUME_AT=""
1345
+ RS_PARK_LOOP_CYCLES=""
1346
+ RS_BUNDLE=0
1347
+ RS_DOWNLOAD=""
1348
+ remote_state() {
1349
+ local download="${1-}" applied="${2-}" newest status_file older bundle_exists=0
1350
+ RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
1351
+ RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
1352
+ RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
1353
+ RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
1354
+ newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
971
1355
  if [ -z "$newest" ]; then
972
- echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
1356
+ RS_STATE=none
973
1357
  return 0
974
1358
  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"
1359
+ RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
1360
+ RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
1361
+ RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
1362
+ if [ "$RS_GH_STATUS" != completed ]; then
1363
+ RS_STATE=running
983
1364
  return 0
984
1365
  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"
1366
+ if [ -n "$applied" ] && [ "$RS_RUN_ID" = "$applied" ]; then
1367
+ RS_STATE=applied
1002
1368
  return 0
1003
1369
  fi
1004
1370
 
1005
- # Case 2 — a newer run whose bundle has expired.
1006
- bundle_state "$id"
1371
+ # Case 2 — its bundle has expired.
1372
+ bundle_state "$RS_RUN_ID"
1007
1373
  if [ "$BUNDLE_STATE" = expired ]; then
1008
- sync_expired "$id" "$url" "$now"
1374
+ RS_STATE=paused
1375
+ RS_PAUSE_REASON=expired
1376
+ RS_DETAIL=$(expired_line "$RS_RUN_ID")
1009
1377
  return 0
1010
1378
  fi
1011
1379
 
1012
- # Case 3 — a newer run with a bundle.
1380
+ # Case 3 — a bundle.
1013
1381
  if [ "$BUNDLE_STATE" = present ]; then
1014
1382
  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
- }
1383
+ if [ -z "$download" ]; then
1384
+ download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$RS_RUN_ID") || {
1385
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
1386
+ exit "$EXIT_USAGE"
1387
+ }
1388
+ fi
1389
+ RS_DOWNLOAD="$download"
1019
1390
  status_file="$download/$HR_REMOTE_STATUS_FILE"
1020
1391
  if [ ! -f "$status_file" ]; then
1021
1392
  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"
1393
+ 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
1394
  fi
1024
- status=$(hr_remote_status_get "$status_file" status) || status=""
1025
- case "$status" in
1395
+ RS_BUNDLE=1
1396
+ RS_STATE=$(hr_remote_status_get "$status_file" status) || RS_STATE=""
1397
+ case "$RS_STATE" in
1026
1398
  running|parked|park_loop|paused|completed|failed) ;;
1027
1399
  *)
1028
1400
  echo "remote-run.sh: refused, nothing written: the bundle in '$download' is unrecognised" >&2
1029
1401
  exit "$EXIT_REFUSED"
1030
1402
  ;;
1031
1403
  esac
1404
+ RS_PAUSE_REASON=$(hr_remote_status_get "$status_file" pause_reason) || RS_PAUSE_REASON=""
1405
+ RS_DETAIL=$(hr_remote_status_get "$status_file" detail) || RS_DETAIL=""
1406
+ if [ "$RS_STATE" = running ]; then
1407
+ RS_STATE=paused
1408
+ RS_PAUSE_REASON=killed
1409
+ RS_DETAIL="the job ended mid-run (its bundle still says running): $RS_RUN_URL"
1410
+ fi
1411
+ RS_USAGE_RESUME_AT=$(hr_remote_status_get "$status_file" usage_resume_at) || RS_USAGE_RESUME_AT=""
1412
+ RS_PARK_LOOP_CYCLES=$(hr_remote_status_get "$status_file" park_loop_cycles) || RS_PARK_LOOP_CYCLES=""
1413
+ RS_ENGINE=$(hr_remote_status_get "$status_file" engine) || RS_ENGINE=""
1414
+ return 0
1415
+ fi
1416
+
1417
+ # Case 4 — no bundle, while some bundle exists.
1418
+ if [ -n "$applied" ]; then
1419
+ bundle_exists=1
1420
+ else
1421
+ for older in $(printf '%s' "$RS_RUNS" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
1422
+ if has_bundle "$older"; then bundle_exists=1; break; fi
1423
+ done
1424
+ fi
1425
+ if [ "$bundle_exists" -eq 1 ]; then
1426
+ RS_STATE=paused
1427
+ RS_PAUSE_REASON=killed
1428
+ RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
1429
+ return 0
1430
+ fi
1431
+
1432
+ # Case 5 — no bundle in any run.
1433
+ RS_STATE=failed
1434
+ RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
1435
+ return 0
1436
+ }
1437
+
1438
+ verb_sync() {
1439
+ local worktree id url synced_id now download
1440
+ local status reason detail
1441
+ worktree=$(hr_registry_get "$registry" "$branch" worktree)
1442
+ if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
1443
+ echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
1444
+ exit "$EXIT_REFUSED"
1445
+ fi
1446
+ list_runs
1447
+ synced_id=$(hr_registry_get "$registry" "$branch" remote_run_id)
1448
+ now=$(date +%s)
1449
+ remote_state "" "$synced_id"
1450
+ id="$RS_RUN_ID"
1451
+ url="$RS_RUN_URL"
1452
+
1453
+ case "$RS_STATE" in
1454
+ none)
1455
+ echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
1456
+ return 0
1457
+ ;;
1458
+ running)
1459
+ set_many_or_fail status running remote_synced_at "$now"
1460
+ echo "remote-run.sh: run $id of $branch is $RS_GH_STATUS; the record is running, nothing downloaded"
1461
+ return 0
1462
+ ;;
1463
+ applied)
1464
+ # Case 1 — already applied. A record still waiting on this run's bundle
1465
+ # is re-checked: once it expires, the job can no longer take an answer.
1466
+ case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
1467
+ paused/expired) ;;
1468
+ parked/*|park_loop/*|paused/*)
1469
+ bundle_state "$id"
1470
+ if [ "$BUNDLE_STATE" = expired ]; then
1471
+ sync_expired "$id" "$url" "$now"
1472
+ return 0
1473
+ fi
1474
+ ;;
1475
+ esac
1476
+ set_or_fail remote_synced_at "$now"
1477
+ echo "remote-run.sh: run $id of $branch is the one last synced; the mirror is current"
1478
+ return 0
1479
+ ;;
1480
+ esac
1481
+
1482
+ # Case 2 — a newer run whose bundle has expired.
1483
+ if [ "$RS_PAUSE_REASON" = expired ] && [ "$RS_BUNDLE" -eq 0 ]; then
1484
+ sync_expired "$id" "$url" "$now"
1485
+ return 0
1486
+ fi
1487
+
1488
+ # Case 3 — a newer run with a bundle.
1489
+ if [ "$RS_BUNDLE" -eq 1 ]; then
1490
+ download="$RS_DOWNLOAD"
1032
1491
  hr_remote_bundle_restore "$download" "$worktree" "$branch" mirror
1033
1492
  case $? in
1034
1493
  0) ;;
@@ -1043,44 +1502,33 @@ verb_sync() {
1043
1502
  || { echo "remote-run.sh: copying the run log to '$remote_log' failed" >&2; exit "$EXIT_USAGE"; }
1044
1503
  fi
1045
1504
  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
1505
+ status="$RS_STATE"
1506
+ reason="$RS_PAUSE_REASON"
1507
+ detail="$RS_DETAIL"
1053
1508
  [ -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"
1509
+ if valid_engine "$RS_ENGINE"; then
1510
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
1511
+ park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
1512
+ remote_detail "$detail" remote_synced_at "$now" engine "$RS_ENGINE"
1513
+ else
1514
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
1515
+ park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
1516
+ remote_detail "$detail" remote_synced_at "$now"
1517
+ fi
1059
1518
  echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
1060
1519
  return 0
1061
1520
  fi
1062
1521
 
1063
1522
  # 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
1523
+ if [ "$RS_STATE" = paused ]; then
1073
1524
  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"
1525
+ remote_detail "$RS_DETAIL" remote_synced_at "$now"
1076
1526
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
1077
1527
  return 0
1078
1528
  fi
1079
1529
 
1080
1530
  # 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"
1531
+ set_many_or_fail status failed remote_detail "$RS_DETAIL" remote_synced_at "$now"
1084
1532
  echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
1085
1533
  }
1086
1534
 
@@ -1315,7 +1763,7 @@ ALL_RUNS_LISTED=0
1315
1763
  list_all_runs() {
1316
1764
  [ "$ALL_RUNS_LISTED" -eq 0 ] || return 0
1317
1765
  gh_call run list --workflow "$WORKFLOW_RUN_FILE" \
1318
- --json databaseId,headBranch,displayTitle,status,createdAt --limit "$ALL_RUNS_LIMIT" || return 1
1766
+ --json databaseId,headBranch,displayTitle,status,createdAt,url --limit "$ALL_RUNS_LIMIT" || return 1
1319
1767
  printf '%s' "$GH_OUT" | jq -e 'type == "array"' >/dev/null 2>&1 || {
1320
1768
  GH_ERR="its run list is not the expected JSON"
1321
1769
  return 1
@@ -1389,13 +1837,13 @@ RESUME_HINT="/autonomous-sdlc-harness:branch-resume"
1389
1837
  continue_redispatch() {
1390
1838
  local status_file="$1" engine_value
1391
1839
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
1392
- notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue."
1840
+ notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")."
1393
1841
  return 0
1394
1842
  fi
1395
1843
  remote_branch_stopped "$branch"
1396
1844
  case $? in
1397
1845
  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 ;;
1846
+ 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" "")."; return 0 ;;
1399
1847
  esac
1400
1848
  if ! max_chain_var; then
1401
1849
  notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer."
@@ -1412,19 +1860,19 @@ continue_redispatch() {
1412
1860
  return 0
1413
1861
  fi
1414
1862
  redispatch "$engine_value" "$NEXT_CHAIN" \
1415
- || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue."
1863
+ || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "$engine_value")."
1416
1864
  }
1417
1865
 
1418
1866
  continue_wait_poller() {
1419
1867
  remote_branch_stopped "$branch"
1420
1868
  case $? in
1421
1869
  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 ;;
1870
+ 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" "")."; return 0 ;;
1423
1871
  esac
1424
1872
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
1425
1873
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
1426
1874
  else
1427
- notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset."
1875
+ 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" "")."
1428
1876
  fi
1429
1877
  }
1430
1878
 
@@ -1648,7 +2096,7 @@ poll_branch() {
1648
2096
  if [ "$failures" -ge "$((10#$POLL_MAX_FAILURES))" ] \
1649
2097
  || [ "$now" -gt "$((10#$at + 10#$POLL_GIVE_UP_MINUTES * 60))" ]; then
1650
2098
  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."
2099
+ 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")."
1652
2100
  return 1
1653
2101
  fi
1654
2102
  poll_state_put "$branch" "$id" "$failures" ""
@@ -1705,7 +2153,7 @@ poll_recheck() {
1705
2153
  return 0
1706
2154
  fi
1707
2155
  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."
2156
+ notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $b after the usage reset; $(hr_github_resume_route "$b" "")."
1709
2157
  done
1710
2158
  }
1711
2159
 
@@ -1778,6 +2226,626 @@ verb_run_created_at() {
1778
2226
  printf '%s\n' "$epoch"
1779
2227
  }
1780
2228
 
2229
+ # placement_fail <step> — exit 4, naming the step; nothing was dispatched.
2230
+ placement_fail() {
2231
+ echo "remote-run.sh: start of '$branch' failed at $1; nothing was dispatched" >&2
2232
+ exit "$EXIT_PLACEMENT"
2233
+ }
2234
+
2235
+ # start_remove_copy — remove the working copy and the local branch `start`
2236
+ # cut, and only those: `had_copy` / `had_branch` record what existed before the
2237
+ # cut. Idempotent; a failed step is one stderr line and never changes the exit
2238
+ # status already decided. `--force` because a failure exit may leave the placed,
2239
+ # uncommitted prompt, which is only a copy of --prompt-file.
2240
+ start_remove_copy() {
2241
+ if [ "$had_copy" -eq 0 ] && [ -e "$worktree" ]; then
2242
+ git -C "$root" worktree remove --force "$worktree" >/dev/null 2>&1 \
2243
+ || echo "remote-run.sh: could not remove the working copy '$worktree'" >&2
2244
+ fi
2245
+ git -C "$root" worktree prune >/dev/null 2>&1 \
2246
+ || echo "remote-run.sh: git worktree prune failed in '$root'" >&2
2247
+ if [ "$had_branch" -eq 0 ] && git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then
2248
+ git -C "$root" branch -D "$branch" >/dev/null 2>&1 \
2249
+ || echo "remote-run.sh: could not delete the local branch '$branch'" >&2
2250
+ fi
2251
+ return 0
2252
+ }
2253
+
2254
+ verb_start() {
2255
+ local protected=0 status=0 state_rel rel subject
2256
+ hr_branch_is_protected "$root" "$branch" || protected=$?
2257
+ case "$protected" in
2258
+ 0)
2259
+ echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
2260
+ exit "$EXIT_REFUSED" ;;
2261
+ 2)
2262
+ echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
2263
+ exit "$EXIT_REFUSED" ;;
2264
+ esac
2265
+ if [ ! -f "$prompt_file" ] || [ ! -r "$prompt_file" ]; then
2266
+ echo "remote-run.sh: refused, nothing written: the prompt file '$prompt_file' is not a readable regular file" >&2
2267
+ exit "$EXIT_REFUSED"
2268
+ fi
2269
+
2270
+ # Global, not local: the EXIT trap runs after this function's frame is gone.
2271
+ worktree=$(hr_worktree_dir "$root" "$branch") || placement_fail "resolving the working copy"
2272
+ had_copy=0
2273
+ [ ! -e "$worktree" ] || had_copy=1
2274
+ had_branch=0
2275
+ ! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
2276
+
2277
+ # The cut itself can leave a half-created copy, and every failure below exits.
2278
+ trap start_remove_copy EXIT
2279
+ bash "$script_dir/create-worktree.sh" --no-bootstrap "$branch" >&2 || status=$?
2280
+ [ "$status" -eq 0 ] || placement_fail "the branch cut (create-worktree.sh exited $status)"
2281
+
2282
+ state_rel=$(hr_state_dir "$worktree") || placement_fail "resolving the state directory in '$worktree'"
2283
+ [ -n "$state_rel" ] || placement_fail "resolving the state directory in '$worktree'"
2284
+ rel=$(hr_task_prompt_rel "$state_rel" "$branch")
2285
+ subject=$(hr_task_prompt_subject "$branch")
2286
+
2287
+ hr_place_artifact "$worktree" "$prompt_file" "$rel" || placement_fail "copying the prompt to '$worktree/$rel'"
2288
+ # 3 (nothing staged) cannot happen on a freshly cut branch, so it reads as 0.
2289
+ status=0
2290
+ hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
2291
+ [ "$status" -ne 1 ] || placement_fail "committing '$rel'"
2292
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
2293
+ || placement_fail "pushing $branch (origin/$branch is not HEAD)"
2294
+ start_remove_copy
2295
+ trap - EXIT
2296
+
2297
+ engine=task
2298
+ resume=none
2299
+ chain=0
2300
+ dispatch_fail_note="; $branch and its task prompt are already pushed to origin, so re-send with: remote-run.sh dispatch $branch --engine task"
2301
+ verb_dispatch
2302
+ echo "remote-run.sh: started $branch"
2303
+ }
2304
+
2305
+ # ---------------------------------------------------------------------------
2306
+ # `fetch` — the newest remote state of one branch, for the plugin commands.
2307
+ # ---------------------------------------------------------------------------
2308
+
2309
+ verb_fetch() {
2310
+ local open_questions="" bundle_dir=""
2311
+ if [ ! -d "$out_dir" ]; then
2312
+ echo "remote-run.sh: fetch needs an existing directory, not '$out_dir'" >&2
2313
+ exit "$EXIT_USAGE"
2314
+ fi
2315
+ if [ -n "$(ls -A "$out_dir" 2>/dev/null)" ]; then
2316
+ echo "remote-run.sh: fetch needs an empty directory, and '$out_dir' is not" >&2
2317
+ exit "$EXIT_USAGE"
2318
+ fi
2319
+ list_runs
2320
+ remote_state "$out_dir"
2321
+ if [ "$RS_BUNDLE" -eq 1 ]; then
2322
+ bundle_dir="$out_dir"
2323
+ open_questions_in "$out_dir"
2324
+ open_questions="$OPEN_QUESTIONS"
2325
+ fi
2326
+ printf 'run_id: %s\n' "$RS_RUN_ID"
2327
+ printf 'run_url: %s\n' "$RS_RUN_URL"
2328
+ printf 'run_status: %s\n' "$RS_GH_STATUS"
2329
+ printf 'state: %s\n' "$RS_STATE"
2330
+ printf 'pause_reason: %s\n' "$RS_PAUSE_REASON"
2331
+ printf 'engine: %s\n' "$RS_ENGINE"
2332
+ printf 'detail: %s\n' "$RS_DETAIL"
2333
+ printf 'open_questions: %s\n' "$open_questions"
2334
+ printf 'bundle_dir: %s\n' "$bundle_dir"
2335
+ }
2336
+
2337
+ # ---------------------------------------------------------------------------
2338
+ # `review` — a user review round placed on the branch tip, then dispatched.
2339
+ # ---------------------------------------------------------------------------
2340
+
2341
+ # review_fail <step> — exit 4, naming the step; nothing was dispatched.
2342
+ review_fail() {
2343
+ echo "remote-run.sh: review of '$branch' failed at $1; nothing was dispatched" >&2
2344
+ exit "$EXIT_PLACEMENT"
2345
+ }
2346
+
2347
+ # remote_record_exists <registry> — 0 when <registry> is a file holding a
2348
+ # record of the branch with `execution: github-actions`. Tested with -f first:
2349
+ # hr_registry_get creates an absent registry.
2350
+ remote_record_exists() {
2351
+ [ -n "${1-}" ] && [ -f "$1" ] && [ "$(hr_registry_get "$1" "$branch" execution)" = github-actions ]
2352
+ }
2353
+
2354
+ verb_review() {
2355
+ local protected=0 status=0 reg="" record_wt="" use_mirror=0 state_rel names name round max=0 next rel
2356
+ hr_branch_is_protected "$root" "$branch" || protected=$?
2357
+ case "$protected" in
2358
+ 0)
2359
+ echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
2360
+ exit "$EXIT_REFUSED" ;;
2361
+ 2)
2362
+ echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
2363
+ exit "$EXIT_REFUSED" ;;
2364
+ esac
2365
+ if [ ! -f "$review_file" ] || [ ! -r "$review_file" ]; then
2366
+ echo "remote-run.sh: refused, nothing written: the review file '$review_file' is not a readable regular file" >&2
2367
+ exit "$EXIT_REFUSED"
2368
+ fi
2369
+
2370
+ # A run in flight takes no review: the local rule, `completed` or `failed` only.
2371
+ list_runs
2372
+ remote_state ""
2373
+ case "$RS_STATE" in
2374
+ completed|failed) ;;
2375
+ none)
2376
+ echo "remote-run.sh: refused, nothing written: no \`harness run $branch\` run on GitHub" >&2
2377
+ exit "$EXIT_REFUSED" ;;
2378
+ paused)
2379
+ if [ "$RS_PAUSE_REASON" = expired ]; then
2380
+ echo "remote-run.sh: refused, nothing written: $RS_DETAIL" >&2
2381
+ else
2382
+ 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
2383
+ fi
2384
+ exit "$EXIT_REFUSED" ;;
2385
+ *)
2386
+ echo "remote-run.sh: refused, nothing written: $branch is $RS_STATE on GitHub; a review waits until its run is completed or failed" >&2
2387
+ exit "$EXIT_REFUSED" ;;
2388
+ esac
2389
+
2390
+ # The copy: the remote record's mirror when it is on the branch, never
2391
+ # removed; else a copy this verb cuts and removes on every exit.
2392
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
2393
+ if remote_record_exists "$reg"; then
2394
+ record_wt=$(hr_registry_get "$reg" "$branch" worktree)
2395
+ if [ -n "$record_wt" ] && [ -d "$record_wt" ] \
2396
+ && [ "$(git -C "$record_wt" symbolic-ref --short HEAD 2>/dev/null)" = "$branch" ]; then
2397
+ use_mirror=1
2398
+ fi
2399
+ fi
2400
+ if [ "$use_mirror" -eq 1 ]; then
2401
+ worktree="$record_wt"
2402
+ else
2403
+ # Global, not local: the EXIT trap runs after this function's frame is gone.
2404
+ worktree=$(hr_worktree_dir "$root" "$branch") || review_fail "resolving the working copy"
2405
+ had_copy=0
2406
+ [ ! -e "$worktree" ] || had_copy=1
2407
+ had_branch=0
2408
+ ! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
2409
+ trap start_remove_copy EXIT
2410
+ bash "$script_dir/create-worktree.sh" --existing --no-bootstrap "$branch" >&2 || status=$?
2411
+ [ "$status" -eq 0 ] || review_fail "the working copy (create-worktree.sh exited $status)"
2412
+ fi
2413
+ # Also for a cut copy: a local branch that existed before may be behind origin.
2414
+ git -C "$worktree" fetch origin "$branch" >&2 || review_fail "git fetch origin $branch in '$worktree'"
2415
+ git -C "$worktree" merge --ff-only "origin/$branch" >&2 || review_fail "git merge --ff-only origin/$branch in '$worktree'"
2416
+
2417
+ # The round, from the branch tip: the engine's own round source.
2418
+ state_rel=$(hr_state_dir "$worktree") || review_fail "resolving the state directory in '$worktree'"
2419
+ [ -n "$state_rel" ] || review_fail "resolving the state directory in '$worktree'"
2420
+ state_rel="${state_rel%/}"
2421
+ names=$(git -C "$worktree" ls-tree --name-only HEAD -- "$state_rel/user_reviews/") \
2422
+ || review_fail "listing $state_rel/user_reviews/ on $branch"
2423
+ while IFS= read -r name; do
2424
+ name="${name##*/}"
2425
+ [[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
2426
+ [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
2427
+ round="${BASH_REMATCH[3]:-1}"
2428
+ round=$((10#$round))
2429
+ [ "$round" -le "$max" ] || max="$round"
2430
+ done <<NAMES
2431
+ $names
2432
+ NAMES
2433
+ if [ "$max" -eq 0 ]; then
2434
+ round=1
2435
+ next="${branch}_review.md"
2436
+ else
2437
+ round=$((max + 1))
2438
+ next="${branch}_review_$round.md"
2439
+ fi
2440
+ rel="$state_rel/user_reviews/$next"
2441
+
2442
+ hr_place_artifact "$worktree" "$review_file" "$rel" || review_fail "copying the review to '$worktree/$rel'"
2443
+ # 3 (nothing staged) cannot happen for a new round's file, so it reads as a failure.
2444
+ status=0
2445
+ hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
2446
+ [ "$status" -eq 0 ] || review_fail "committing '$rel'"
2447
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
2448
+ || review_fail "pushing $branch (origin/$branch is not HEAD)"
2449
+ if [ "$use_mirror" -eq 0 ]; then
2450
+ start_remove_copy
2451
+ trap - EXIT
2452
+ fi
2453
+
2454
+ echo "remote-run.sh: placed $rel (round $round) on $branch"
2455
+ engine=user_review
2456
+ resume=none
2457
+ chain=0
2458
+ dispatch_fail_note="; the review is already pushed to origin/$branch, so re-send with: remote-run.sh dispatch $branch --engine user_review"
2459
+ verb_dispatch
2460
+
2461
+ if remote_record_exists "$reg"; then
2462
+ hr_registry_set "$reg" "$branch" status running engine user_review \
2463
+ || echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
2464
+ fi
2465
+ }
2466
+
2467
+ # unrecorded_runs — UNRECORDED: one `<branch>\t<url>` line per branch whose
2468
+ # newest `harness run <branch>` run is on GitHub, newest first, dropping a
2469
+ # branch with a registry record, one `hr_branch_is_protected` does not answer 1
2470
+ # for, and one that is not a live head on origin. One listing and one
2471
+ # `ls-remote`; writes nothing. Exits 3 when either fails.
2472
+ UNRECORDED=""
2473
+ unrecorded_runs() {
2474
+ local titled reg recorded="" heads="" live=$'\n' ref b url protected
2475
+ UNRECORDED=""
2476
+ reg=$(hr_state_path "$root" autonomous_logs/registry.json) || {
2477
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
2478
+ exit "$EXIT_USAGE"
2479
+ }
2480
+ list_all_runs || gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
2481
+ # Newest first, one line per branch: its newest `harness run <branch>` run.
2482
+ titled=$(printf '%s' "$ALL_RUNS" | jq -r '
2483
+ [.[] | select((.displayTitle // "") | startswith("harness run "))
2484
+ | . + {b: (.displayTitle | ltrimstr("harness run "))}]
2485
+ | sort_by([.createdAt, .databaseId]) | reverse
2486
+ | reduce .[] as $r ({seen: {}, out: []};
2487
+ if .seen[$r.b] then . else (.seen[$r.b] = true | .out += [$r]) end)
2488
+ | .out[] | "\(.b)\t\(.url // "")"' 2>/dev/null) || {
2489
+ GH_ERR="its run list is not the expected JSON"
2490
+ gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
2491
+ }
2492
+
2493
+ # Tested with -f first: hr_registry_get creates an absent registry.
2494
+ if [ -f "$reg" ]; then
2495
+ recorded=$(jq -r '.runs | keys[]' "$reg" 2>/dev/null) || {
2496
+ echo "remote-run.sh: cannot read the registry '$reg'" >&2
2497
+ exit "$EXIT_USAGE"
2498
+ }
2499
+ fi
2500
+ recorded=$'\n'"$recorded"$'\n'
2501
+
2502
+ if [ -n "$titled" ]; then
2503
+ heads=$(git -C "$root" ls-remote --heads origin 2>&1) || {
2504
+ echo "remote-run.sh: reading origin's branches failed: ${heads%%$'\n'*}" >&2
2505
+ exit "$EXIT_GH"
2506
+ }
2507
+ while IFS=$'\t' read -r _ ref; do
2508
+ case "$ref" in refs/heads/*) live="$live${ref#refs/heads/}"$'\n' ;; esac
2509
+ done <<EOF
2510
+ $heads
2511
+ EOF
2512
+ fi
2513
+
2514
+ while IFS=$'\t' read -r b url; do
2515
+ valid_branch "$b" || continue
2516
+ case "$recorded" in *$'\n'"$b"$'\n'*) continue ;; esac
2517
+ protected=0
2518
+ hr_branch_is_protected "$root" "$b" || protected=$?
2519
+ [ "$protected" -eq 1 ] || continue
2520
+ case "$live" in *$'\n'"$b"$'\n'*) ;; *) continue ;; esac
2521
+ UNRECORDED="$UNRECORDED$b"$'\t'"$url"$'\n'
2522
+ done <<EOF
2523
+ $titled
2524
+ EOF
2525
+ }
2526
+
2527
+ # ---------------------------------------------------------------------------
2528
+ # `list` — the runs on GitHub with no local record, for `branch-status`.
2529
+ # ---------------------------------------------------------------------------
2530
+
2531
+ verb_list() {
2532
+ local b url
2533
+ unrecorded_runs
2534
+ if [ -z "$UNRECORDED" ]; then
2535
+ echo "remote-run.sh: no run on GitHub without a local record"
2536
+ return 0
2537
+ fi
2538
+ while IFS=$'\t' read -r b url; do
2539
+ [ -n "$b" ] || continue
2540
+ echo "remote-run.sh: on GitHub, no local record: $b $url"
2541
+ done <<EOF
2542
+ $UNRECORDED
2543
+ EOF
2544
+ }
2545
+
2546
+ # ---------------------------------------------------------------------------
2547
+ # `trigger` — the event adapter. Event text is data: every field is read by
2548
+ # `jq` into a variable and reaches a command only as one argument or as file
2549
+ # bytes, never as shell source.
2550
+ # ---------------------------------------------------------------------------
2551
+
2552
+ trigger_tmp=""
2553
+ trigger_label=""
2554
+ issue_number=""
2555
+ # `issue` or `dispatch`: where trigger_finish reports.
2556
+ trigger_source=""
2557
+
2558
+ # event_field <jq filter> — one field of the event into EVENT_VALUE, its bytes
2559
+ # kept (a command substitution alone would drop trailing newlines). 1 when jq
2560
+ # cannot read it.
2561
+ EVENT_VALUE=""
2562
+ event_field() {
2563
+ local out
2564
+ out=$(jq -j "$1" "$GITHUB_EVENT_PATH" 2>/dev/null && printf x) || return 1
2565
+ EVENT_VALUE=${out%x}
2566
+ }
2567
+
2568
+ # trigger_finish <exit> <comment> — post <comment> on the issue, remove the
2569
+ # trigger label, and exit <exit>. A comment that cannot be posted makes the exit
2570
+ # 3; a label that cannot be removed is a warning only. For a dispatch event,
2571
+ # print <comment> and append it to GITHUB_STEP_SUMMARY when set; a summary that
2572
+ # cannot be appended is a warning only, since stdout already carries it.
2573
+ trigger_finish() {
2574
+ local code="$1" body="$2" file
2575
+ if [ "$trigger_source" = dispatch ]; then
2576
+ printf '%s\n' "$body"
2577
+ if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
2578
+ printf '%s\n' "### harness trigger" "" "$body" "" >>"$GITHUB_STEP_SUMMARY" \
2579
+ || echo "::warning::remote-run.sh: trigger: cannot append to GITHUB_STEP_SUMMARY"
2580
+ fi
2581
+ exit "$code"
2582
+ fi
2583
+ if [ -n "${GITHUB_RUN_ID-}" ]; then
2584
+ body="$body
2585
+
2586
+ _Posted by the trigger job ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/runs/$GITHUB_RUN_ID._"
2587
+ fi
2588
+ if ! file=$(mktemp "$trigger_tmp/harness-trigger-comment.XXXXXX"); then
2589
+ echo "::error::remote-run.sh: trigger: cannot create the comment file for issue #$issue_number under '$trigger_tmp'"
2590
+ exit "$EXIT_GH"
2591
+ fi
2592
+ printf '%s\n' "$body" >"$file"
2593
+ if ! gh_call issue comment "$issue_number" --repo "${GITHUB_REPOSITORY-}" --body-file "$file"; then
2594
+ echo "::error::remote-run.sh: trigger: the comment on issue #$issue_number could not be posted: $GH_ERR"
2595
+ code="$EXIT_GH"
2596
+ fi
2597
+ rm -f "$file"
2598
+ if ! gh_call issue edit "$issue_number" --repo "${GITHUB_REPOSITORY-}" --remove-label "$trigger_label"; then
2599
+ echo "::warning::remote-run.sh: trigger: removing the label '$trigger_label' from issue #$issue_number failed: $GH_ERR"
2600
+ fi
2601
+ exit "$code"
2602
+ }
2603
+
2604
+ # trigger_refuse <reason> <way on> — print the reason, comment both, exit 2.
2605
+ trigger_refuse() {
2606
+ echo "remote-run.sh: trigger: refused, nothing sent: $1" >&2
2607
+ trigger_finish "$EXIT_REFUSED" "No run started: $1
2608
+
2609
+ $2"
2610
+ }
2611
+
2612
+ # trigger_bot_listed <login> — 0 when <login> is an exact entry of
2613
+ # HARNESS_TRIGGER_ALLOWED_BOTS, split on `,` with each entry trimmed.
2614
+ trigger_bot_listed() {
2615
+ local rest="${HARNESS_TRIGGER_ALLOWED_BOTS-}," entry
2616
+ while [ -n "$rest" ]; do
2617
+ entry=${rest%%,*}
2618
+ rest=${rest#*,}
2619
+ entry=${entry#"${entry%%[![:space:]]*}"}
2620
+ entry=${entry%"${entry##*[![:space:]]}"}
2621
+ [ -n "$entry" ] && [ "$entry" = "$1" ] && return 0
2622
+ done
2623
+ return 1
2624
+ }
2625
+
2626
+ # trigger_run_url — the URL of the `harness run <branch>` run `start` just
2627
+ # dispatched, looked up at most TRIGGER_RUN_LOOKUP_TRIES times; the branch's
2628
+ # filtered run list when none appears. Never fails.
2629
+ trigger_run_url() {
2630
+ local try=1 secs url=""
2631
+ secs="${HARNESS_TRIGGER_LOOKUP_SECS-}"
2632
+ case "$secs" in
2633
+ ''|*[!0-9]*) secs="$TRIGGER_LOOKUP_SECS_DEFAULT" ;;
2634
+ esac
2635
+ while :; do
2636
+ if gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json url,displayTitle --limit 5; then
2637
+ url=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" \
2638
+ '[.[]? | select(.displayTitle == $t) | .url | strings] | first // empty' 2>/dev/null) || url=""
2639
+ [ -z "$url" ] || break
2640
+ else
2641
+ echo "remote-run.sh: trigger: looking up the run of $branch failed: $GH_ERR" >&2
2642
+ fi
2643
+ [ "$try" -lt "$TRIGGER_RUN_LOOKUP_TRIES" ] || break
2644
+ try=$((try + 1))
2645
+ sleep "$secs"
2646
+ done
2647
+ # A derived name is `[a-z0-9_]` only, so it needs no encoding in the query.
2648
+ [ -n "$url" ] || url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/workflows/${WORKFLOW_RUN_FILE}?query=branch%3A$branch"
2649
+ printf '%s\n' "$url"
2650
+ }
2651
+
2652
+ verb_trigger() {
2653
+ local LC_ALL=C
2654
+ local action label title body html_url state login sender_type source=""
2655
+ local forge="" target="" default name_file status permission prompt errfile last url
2656
+ local fallback retry_then retry_again task_what
2657
+ case "${GITHUB_EVENT_NAME-}" in
2658
+ issues) trigger_source=issue ;;
2659
+ repository_dispatch) trigger_source=dispatch ;;
2660
+ *)
2661
+ echo "remote-run.sh: trigger handles GITHUB_EVENT_NAME issues or repository_dispatch, not '${GITHUB_EVENT_NAME-}'" >&2
2662
+ exit "$EXIT_USAGE" ;;
2663
+ esac
2664
+ if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
2665
+ echo "remote-run.sh: trigger: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
2666
+ exit "$EXIT_USAGE"
2667
+ fi
2668
+ hr_have_jq || { echo "remote-run.sh: trigger needs jq" >&2; exit "$EXIT_USAGE"; }
2669
+
2670
+ if [ "$trigger_source" = issue ]; then
2671
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
2672
+ && event_field '.label.name // ""' && label="$EVENT_VALUE" \
2673
+ && event_field '.issue.number // ""' && issue_number="$EVENT_VALUE" \
2674
+ && event_field '.issue.title // ""' && title="$EVENT_VALUE" \
2675
+ && event_field '.issue.body // ""' && body="$EVENT_VALUE" \
2676
+ && event_field '.issue.html_url // ""' && html_url="$EVENT_VALUE" \
2677
+ && event_field '.issue.state // ""' && state="$EVENT_VALUE" \
2678
+ && event_field '.sender.login // ""' && login="$EVENT_VALUE" \
2679
+ && event_field '.sender.type // ""' && sender_type="$EVENT_VALUE"; } || {
2680
+ echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
2681
+ exit "$EXIT_USAGE"
2682
+ }
2683
+ trigger_label="${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}"
2684
+ if [ "$action" != labeled ] || [ "$label" != "$trigger_label" ]; then
2685
+ echo "remote-run.sh: trigger: ignored, not the label '$trigger_label' being applied"
2686
+ return 0
2687
+ fi
2688
+ case "$issue_number" in
2689
+ ''|*[!0-9]*|0*)
2690
+ echo "remote-run.sh: trigger: the event carries no issue number" >&2
2691
+ exit "$EXIT_USAGE" ;;
2692
+ esac
2693
+ fallback="issue_$issue_number"
2694
+ retry_then="re-apply the label \`$trigger_label\`"
2695
+ retry_again="Re-apply the label \`$trigger_label\` to try again."
2696
+ task_what="this issue's task"
2697
+ else
2698
+ # GitHub puts a repository_dispatch's `event_type` in `.action`.
2699
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
2700
+ && event_field '.client_payload.title // ""' && title="$EVENT_VALUE" \
2701
+ && event_field '.client_payload.body // ""' && body="$EVENT_VALUE" \
2702
+ && event_field '.client_payload.source // ""' && source="$EVENT_VALUE"; } || {
2703
+ echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
2704
+ exit "$EXIT_USAGE"
2705
+ }
2706
+ if [ "$action" != "$TRIGGER_DISPATCH_EVENT_TYPE" ]; then
2707
+ echo "remote-run.sh: trigger: ignored, a repository_dispatch of type '$action', not '$TRIGGER_DISPATCH_EVENT_TYPE'"
2708
+ return 0
2709
+ fi
2710
+ fallback="task_${GITHUB_RUN_ID-}"
2711
+ retry_then="send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again"
2712
+ retry_again="Send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again to try again."
2713
+ task_what="the dispatched task"
2714
+ fi
2715
+
2716
+ trigger_tmp="${RUNNER_TEMP-}"
2717
+ if [ -z "$trigger_tmp" ] || [ ! -d "$trigger_tmp" ]; then
2718
+ trigger_tmp=$(mktemp -d) || { echo "remote-run.sh: trigger: mktemp failed" >&2; exit "$EXIT_USAGE"; }
2719
+ fi
2720
+
2721
+ if [ "$trigger_source" = dispatch ] && [ -z "$title" ]; then
2722
+ trigger_refuse "the dispatch's \`client_payload\` carries no \`title\`." \
2723
+ "Send it as \`{\"event_type\": \"$TRIGGER_DISPATCH_EVENT_TYPE\", \"client_payload\": {\"title\": …, \"body\": …, \"source\": …}}\`, with a non-empty \`title\`."
2724
+ fi
2725
+
2726
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
2727
+ trigger_refuse "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every start." \
2728
+ "Clear it under **Settings → Secrets and variables → Actions → Variables**, then $retry_then."
2729
+ fi
2730
+
2731
+ forge=$(hr_forge "$root") || forge=""
2732
+ target=$(hr_execution_target "$root") || target=""
2733
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
2734
+ 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})." \
2735
+ "Set both keys on the default branch, then $retry_then."
2736
+ fi
2737
+
2738
+ if [ "$trigger_source" = issue ]; then
2739
+ if [ "$state" != open ]; then
2740
+ trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
2741
+ fi
2742
+
2743
+ if [ "$login" = ghost ] || [ -z "$login" ] \
2744
+ || ! { [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \
2745
+ || { [ "$sender_type" = Bot ] && [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$ ]]; }; }; then
2746
+ trigger_refuse "the label was applied by an account GitHub does not name (a deleted account shows as \`ghost\`)." \
2747
+ "A collaborator with write access can re-apply the label \`$trigger_label\`."
2748
+ fi
2749
+
2750
+ if [ "$sender_type" != User ]; then
2751
+ trigger_bot_listed "$login" || trigger_refuse \
2752
+ "@$login is not a person, and is not listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`." \
2753
+ "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`."
2754
+ else
2755
+ permission=""
2756
+ if gh_call api "repos/${GITHUB_REPOSITORY-}/collaborators/$login/permission"; then
2757
+ permission=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || permission=""
2758
+ case "$permission" in
2759
+ admin|write) ;;
2760
+ *) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${permission:-nothing}\`." \
2761
+ "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\`." ;;
2762
+ esac
2763
+ else
2764
+ trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
2765
+ "Re-apply the label \`$trigger_label\` to try again."
2766
+ fi
2767
+ fi
2768
+ fi
2769
+
2770
+ # The name check reads origin/<defaultBranch>; a failed fetch leaves it to say so.
2771
+ default=$(hr_default_branch "$root") || default=""
2772
+ if [ -n "$default" ]; then
2773
+ git -C "$root" fetch --quiet origin "$default" >&2 || echo "remote-run.sh: trigger: fetching origin $default failed" >&2
2774
+ fi
2775
+ name_file=$(mktemp "$trigger_tmp/harness-trigger-branch.XXXXXX") || trigger_refuse \
2776
+ "the branch name could not be derived (mktemp failed)." "$retry_again"
2777
+ status=0
2778
+ hr_derive_branch "$root" "$title" "$fallback" >"$name_file" || status=$?
2779
+ branch=""
2780
+ IFS= read -r branch <"$name_file" || :
2781
+ rm -f "$name_file"
2782
+ case "$status" in
2783
+ 0) ;;
2784
+ 3) if [ "$trigger_source" = issue ]; then
2785
+ trigger_refuse "every branch name derived from this issue's title, through the suffix \`_99\`, is already taken." \
2786
+ "Retitle the issue, then re-apply the label \`$trigger_label\`."
2787
+ fi
2788
+ trigger_refuse "every branch name derived from the dispatch's \`title\`, through the suffix \`_99\`, is already taken." \
2789
+ "Send the dispatch again with another \`title\`." ;;
2790
+ *) trigger_refuse "the branch name for $task_what could not be checked (${HR_TAKEN_WHY:-no usable name})." \
2791
+ "$retry_again" ;;
2792
+ esac
2793
+
2794
+ prompt=$(mktemp "$trigger_tmp/harness-trigger-prompt.XXXXXX") || trigger_finish "$EXIT_PLACEMENT" \
2795
+ "No run started: the task prompt for \`$branch\` could not be written. $retry_again"
2796
+ if [ "$trigger_source" = issue ]; then
2797
+ 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' \
2798
+ "$title" "$body" "$html_url" "$login" "$trigger_label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
2799
+ else
2800
+ [ -z "$source" ] || source=", from $source"
2801
+ printf '# %s\n\n%s\n\n---\n\nStarted by a repository_dispatch event of type `%s`%s at %s.\n' \
2802
+ "$title" "$body" "$TRIGGER_DISPATCH_EVENT_TYPE" "$source" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
2803
+ fi
2804
+
2805
+ errfile=$(mktemp "$trigger_tmp/harness-trigger-start.XXXXXX") || errfile=/dev/null
2806
+ status=0
2807
+ bash "$script_dir/remote-run.sh" start "$branch" --prompt-file "$prompt" --repo "$root" 2>"$errfile" || status=$?
2808
+ last=""
2809
+ if [ "$errfile" != /dev/null ]; then
2810
+ cat "$errfile" >&2
2811
+ last=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
2812
+ rm -f "$errfile"
2813
+ fi
2814
+ case "$status" in
2815
+ 0) ;;
2816
+ 3)
2817
+ echo "remote-run.sh: trigger: $branch is pushed, but its dispatch failed" >&2
2818
+ trigger_finish "$EXIT_GH" "The branch \`$branch\` was pushed with $task_what, but dispatching its run failed:
2819
+
2820
+ \`\`\`
2821
+ $last
2822
+ \`\`\`
2823
+
2824
+ Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with *Use workflow from* set to \`$branch\`, \`action\` \`run\` and \`branch\` \`$branch\`." ;;
2825
+ *)
2826
+ echo "remote-run.sh: trigger: the start of $branch failed (exit $status)" >&2
2827
+ trigger_finish "$EXIT_PLACEMENT" "No run started: placing $task_what on the branch \`$branch\` failed:
2828
+
2829
+ \`\`\`
2830
+ $last
2831
+ \`\`\`
2832
+
2833
+ $retry_again" ;;
2834
+ esac
2835
+
2836
+ url=$(trigger_run_url)
2837
+ if [ "$trigger_source" = issue ]; then
2838
+ echo "remote-run.sh: trigger: started $branch from issue #$issue_number: $url"
2839
+ trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
2840
+
2841
+ 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."
2842
+ fi
2843
+ echo "remote-run.sh: trigger: started $branch from a repository_dispatch: $url"
2844
+ trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
2845
+
2846
+ The task is the dispatch's \`client_payload\` title and body. Sending the same dispatch again starts another run, on the next indexed branch."
2847
+ }
2848
+
1781
2849
  case "$verb" in
1782
2850
  dispatch) verb_dispatch ;;
1783
2851
  pause) verb_pause ;;
@@ -1791,5 +2859,10 @@ case "$verb" in
1791
2859
  poll) verb_poll ;;
1792
2860
  pause-requested) verb_pause_requested ;;
1793
2861
  run-created-at) verb_run_created_at ;;
2862
+ start) verb_start ;;
2863
+ fetch) verb_fetch ;;
2864
+ review) verb_review ;;
2865
+ trigger) verb_trigger ;;
2866
+ list) verb_list ;;
1794
2867
  esac
1795
2868
  exit "$EXIT_OK"