autonomous-sdlc-harness 0.4.1 → 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
@@ -71,11 +219,15 @@
71
219
  # past a run with none, and stopping at one whose artifact has expired, since
72
220
  # an older copy would be staler state. An expired one restores nothing: under
73
221
  # --resume answer it exits 2; otherwise it prints a `::warning::` line naming
74
- # the run, the expiry and the lost counts and clarification history, and the
75
- # job continues from the committed ledger. An unexpired one it downloads to `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/`
222
+ # the run, the expiry and the lost counts, clarification history and
223
+ # uncommitted planning drafts, and the job continues from the committed ledger. An unexpired one it downloads to `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/`
76
224
  # (skipped when that directory already holds its status.json); and restores it
77
225
  # in `job` mode — on every --resume kind, `none` included, because a reused
78
- # branch keeps its clarification history. Then, under --resume answer, it
226
+ # branch keeps its clarification history. A job-mode restore also places the
227
+ # bundle's `planning/` drafts at their paths under the state directory, never
228
+ # over a file the checkout already has, and when it placed or kept any prints
229
+ # `placed <n> planning file(s) for <branch>; kept <n> the checkout already
230
+ # carries`. Then, under --resume answer, it
79
231
  # writes each `"<n>": "<text>"` entry to `clarifications/<branch>/answer_<n>.md`
80
232
  # with the exact bytes, after checking every entry first; and with
81
233
  # `HARNESS_INPUT_PARK_LOOP_CLEAR` exactly `true` it sets `park_loop_cycles` to
@@ -200,11 +352,28 @@
200
352
  # `gh run view <run_id> --json createdAt` as an epoch second.
201
353
  #
202
354
  # `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
203
- # it started with, so they test the record's `execution` field and never
204
- # `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.
205
367
  #
206
- # `status` WRITES NOTHING AT ALL — no registry (it does not even create an
207
- # 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
208
377
  # `harness run <branch>` or `harness pause <branch>` (bounded), the record's
209
378
  # `status`, `pause_reason`, `remote_run_url` and `remote_synced_at`, whether
210
379
  # a `harness run` finished after the last sync, and — reading the newest
@@ -230,7 +399,9 @@
230
399
  # `mirror` mode into the record's `worktree`, `run.log` copied to the main
231
400
  # checkout's `autonomous_logs/<branch>.remote.log`, and `status`,
232
401
  # `pause_reason`, `usage_resume_at`, `park_loop_cycles`, `remote_run_id`,
233
- # `remote_run_url`, `remote_detail` and `remote_synced_at` written
402
+ # `remote_run_url`, `remote_detail` and `remote_synced_at` written, and
403
+ # `engine` when the bundle names `task`, `user_review` or `docs`. A
404
+ # `mirror` restore places no planning draft
234
405
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
235
406
  # older finished run carries one): a job that died before its upload.
236
407
  # `paused` / `killed`, `remote_run_id` / `remote_run_url` re-pointed at
@@ -289,16 +460,30 @@
289
460
  # root's `harness.config.json`.
290
461
  #
291
462
  # WHAT IT NEVER DOES. It never launches a local session, never writes the
292
- # 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
293
475
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
294
476
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
295
477
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
296
478
  # `hr_remote_bundle_restore` performs in the record's `worktree`; for
297
- # `restore`, that download directory, the job restore, `answer_<n>.md` and the
479
+ # `restore`, that download directory, the job restore (the planning drafts
480
+ # among it), `answer_<n>.md` and the
298
481
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
299
482
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
300
483
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
301
- # 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.
302
487
  #
303
488
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
304
489
  # rename there is an edit here, byte for byte:
@@ -307,6 +492,10 @@
307
492
  # STATE_ARTIFACT_NAME mirrors STATE_ARTIFACT_NAME
308
493
  # POLL_STATE_ARTIFACT_NAME mirrors POLL_STATE_ARTIFACT_NAME
309
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')
310
499
  #
311
500
  # `set -u` WITHOUT `-e`: every refusal is reported with its own exit code rather
312
501
  # than aborting mid-decision.
@@ -339,6 +528,37 @@
339
528
  # bash scripts/remote-run.sh pause feat_x -> 3, names `boom`
340
529
  # gh absent HARNESS_GH_CLI=/nonexistent bash scripts/remote-run.sh warm -> 3
341
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
+ #
342
562
  # status and sync need a remote record, and a `run list` answer whose runs
343
563
  # carry `displayTitle` `harness run feat_x` and a `url`:
344
564
  # r=sdlc-harness/autonomous_logs/registry.json; mkdir -p "${r%/*}"
@@ -363,7 +583,8 @@
363
583
  # flow_walker_state) and GITHUB_RUN_ID set to another id:
364
584
  # restore bash scripts/remote-run.sh restore feat_x --resume none -> 0; the
365
585
  # checkout carries clarifications/feat_x/question_1.md,
366
- # .flow_walker_state and autonomous_logs/remote_status.json
586
+ # .flow_walker_state and autonomous_logs/remote_status.json, and
587
+ # places the bundle's planning/ drafts where the checkout has none
367
588
  # answer HARNESS_INPUT_ANSWERS='{"1":"Use B.\n"}' ... --resume answer -> 0;
368
589
  # clarifications/feat_x/answer_1.md holds exactly `Use B.` + newline
369
590
  # no question HARNESS_INPUT_ANSWERS='{"2":"x"}' ... --resume answer
@@ -381,7 +602,7 @@
381
602
  # -> prints the expired line, "$r" byte-identical
382
603
  # save bash scripts/remote-run.sh save feat_x /tmp/b -> 0; /tmp/b holds
383
604
  # status.json, clarifications/feat_x/, flow_walker_state (and
384
- # PAUSE_PROGRESS.md, run.log when present); with
605
+ # PAUSE_PROGRESS.md, run.log, planning/ when present); with
385
606
  # GITHUB_STEP_SUMMARY=/tmp/s, /tmp/s gains the status table
386
607
  # never started no remote_status.json and no registry: save -> 0, /tmp/b
387
608
  # empty
@@ -425,6 +646,26 @@
425
646
  # run-created-at a stub answering `run view 42 --json createdAt` with
426
647
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
427
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`
428
669
 
429
670
  set -u
430
671
 
@@ -441,6 +682,8 @@ WORKFLOW_RUN_FILE='harness-run.yml'
441
682
  WORKFLOW_RESUME_FILE='harness-resume.yml'
442
683
  STATE_ARTIFACT_NAME='harness-state'
443
684
  POLL_STATE_ARTIFACT_NAME='harness-poll-state'
685
+ DEFAULT_TRIGGER_LABEL='harness'
686
+ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
444
687
  GH="${HARNESS_GH_CLI:-gh}"
445
688
 
446
689
  # How many runs `status` prints, and how many `run list` returns for status
@@ -461,6 +704,10 @@ POLL_GIVE_UP_AFTER_MINUTES_DEFAULT=360
461
704
  # How many of the poller's own runs `poll` searches for the previous tick's
462
705
  # state artifact; each one without it costs an artifact lookup.
463
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
464
711
 
465
712
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
466
713
  # maximum payload for inputs is 65,535 characters."
@@ -471,6 +718,7 @@ EXIT_OK=0
471
718
  EXIT_USAGE=1
472
719
  EXIT_REFUSED=2
473
720
  EXIT_GH=3
721
+ EXIT_PLACEMENT=4
474
722
  # pause-requested only: the read succeeded and found no pause.
475
723
  EXIT_NO_PAUSE=1
476
724
 
@@ -482,12 +730,17 @@ usage() {
482
730
  echo " remote-run.sh stop <branch> [--repo <root>]" >&2
483
731
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
484
732
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
733
+ echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
485
734
  echo " remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]" >&2
486
735
  echo " remote-run.sh save <branch> <out_dir> [--repo <root>]" >&2
487
736
  echo " remote-run.sh continue <branch> <bundle_dir> [--repo <root>]" >&2
488
737
  echo " remote-run.sh poll [--repo <root>]" >&2
489
738
  echo " remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]" >&2
490
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
491
744
  [ "${verb-}" != save ] || exit "$EXIT_OK"
492
745
  exit "$EXIT_USAGE"
493
746
  }
@@ -538,7 +791,7 @@ verb=""
538
791
  verb="$1"
539
792
  shift
540
793
  case "$verb" in
541
- 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) ;;
542
795
  *) usage "unknown verb '$verb'" ;;
543
796
  esac
544
797
 
@@ -556,6 +809,8 @@ chain="0"
556
809
  repo_arg=""
557
810
  since_arg=""
558
811
  run_id_arg=""
812
+ prompt_file=""
813
+ review_file=""
559
814
 
560
815
  while [ "$#" -gt 0 ]; do
561
816
  case "$1" in
@@ -579,10 +834,19 @@ while [ "$#" -gt 0 ]; do
579
834
  --park-loop-clear)
580
835
  [ "$verb" = dispatch ] || usage "$1 is a dispatch option"
581
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 ;;
582
845
  -*)
583
846
  usage "unknown option '$1'" ;;
584
847
  *)
585
- [ "$verb" != warm ] && [ "$verb" != poll ] || usage "$verb takes no branch"
848
+ [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != trigger ] \
849
+ && [ "$verb" != list ] || usage "$verb takes no branch"
586
850
  if [ "$verb" = run-created-at ]; then
587
851
  [ -z "$run_id_arg" ] || usage "unexpected argument '$1'"
588
852
  run_id_arg="$1"
@@ -590,7 +854,7 @@ while [ "$#" -gt 0 ]; do
590
854
  branch="$1"
591
855
  elif [ "$verb" = pause-requested ] && [ -z "$since_arg" ]; then
592
856
  since_arg="$1"
593
- elif [ "$verb" = save ] && [ -z "$out_dir" ]; then
857
+ elif { [ "$verb" = save ] || [ "$verb" = fetch ]; } && [ -z "$out_dir" ]; then
594
858
  out_dir="$1"
595
859
  elif [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
596
860
  bundle_dir="$1"
@@ -601,7 +865,8 @@ while [ "$#" -gt 0 ]; do
601
865
  esac
602
866
  done
603
867
 
604
- 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
605
870
  valid_branch "$branch" || usage "$verb needs a <branch>"
606
871
  fi
607
872
 
@@ -627,6 +892,18 @@ if [ "$verb" = save ] && [ -z "$out_dir" ]; then
627
892
  usage "save needs an <out_dir>"
628
893
  fi
629
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
+
630
907
  if [ "$verb" = restore ]; then
631
908
  [ "$resume_given" -eq 1 ] || usage "restore needs --resume"
632
909
  case "$resume" in
@@ -676,7 +953,7 @@ setup_fail() {
676
953
  if [ -n "$repo_arg" ]; then
677
954
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
678
955
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
679
- || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ]; then
956
+ || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ]; then
680
957
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
681
958
  else
682
959
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -684,6 +961,7 @@ fi
684
961
 
685
962
  hr_config_load "$root" || :
686
963
  registry=""
964
+ status_no_record=0
687
965
  case "$verb" in
688
966
  restore|save|continue|poll)
689
967
  hr_state_path "$root" >/dev/null || setup_fail "cannot resolve '$root/harness.config.json'"
@@ -691,6 +969,9 @@ case "$verb" in
691
969
  pause-requested|run-created-at)
692
970
  # Read verbs: no gate, and nothing of the configuration is read.
693
971
  ;;
972
+ trigger)
973
+ # Gates itself, after reading the event, so a refusal can still be commented.
974
+ ;;
694
975
  status|sync)
695
976
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || {
696
977
  echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
@@ -698,7 +979,18 @@ case "$verb" in
698
979
  }
699
980
  # Tested with -f first: hr_registry_get creates an absent registry, and
700
981
  # status writes nothing.
701
- 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
702
994
  echo "remote-run.sh: refused, nothing written: the local record of '$branch' does not carry execution: github-actions" >&2
703
995
  exit "$EXIT_REFUSED"
704
996
  fi
@@ -720,6 +1012,20 @@ case "$bundle_dir" in
720
1012
  ''|/*) ;;
721
1013
  *) bundle_dir="${PWD-.}/$bundle_dir" ;;
722
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
723
1029
 
724
1030
  cd "$root" || setup_fail "cannot enter '$root'"
725
1031
 
@@ -727,6 +1033,9 @@ cd "$root" || setup_fail "cannot enter '$root'"
727
1033
  # The verbs.
728
1034
  # ---------------------------------------------------------------------------
729
1035
 
1036
+ # Appended to a failed dispatch's message; `start` sets it once its branch is pushed.
1037
+ dispatch_fail_note=""
1038
+
730
1039
  verb_dispatch() {
731
1040
  local answers="" value n file payload
732
1041
  local -a inputs
@@ -763,8 +1072,19 @@ verb_dispatch() {
763
1072
  exit "$EXIT_REFUSED"
764
1073
  fi
765
1074
 
766
- 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"
767
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
768
1088
  }
769
1089
 
770
1090
  verb_pause() {
@@ -914,6 +1234,55 @@ set_many_or_fail() {
914
1234
  }
915
1235
  }
916
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
+
917
1286
  verb_status() {
918
1287
  local runs finished_id synced_id field
919
1288
  list_runs
@@ -922,6 +1291,10 @@ verb_status() {
922
1291
  printf '%s' "$runs" | jq -r --argjson n "$STATUS_RUNS_SHOWN" '
923
1292
  if length == 0 then " (none)" else
924
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
925
1298
  echo "remote-run.sh: local record (last synced):"
926
1299
  for field in status pause_reason remote_run_url remote_synced_at; do
927
1300
  printf ' %s: %s\n' "$field" "$(hr_registry_get "$registry" "$branch" "$field")"
@@ -950,78 +1323,171 @@ sync_expired() {
950
1323
  echo "remote-run.sh: $line"
951
1324
  }
952
1325
 
953
- verb_sync() {
954
- local worktree runs newest id state url synced_id now older download status_file
955
- local status reason detail resume_at cycles
956
- worktree=$(hr_registry_get "$registry" "$branch" worktree)
957
- if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
958
- echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
959
- exit "$EXIT_REFUSED"
960
- fi
961
- list_runs
962
- runs=$(titled_runs "harness run $branch") || runs='[]'
963
- 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')
964
1355
  if [ -z "$newest" ]; then
965
- echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
1356
+ RS_STATE=none
966
1357
  return 0
967
1358
  fi
968
- id=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
969
- state=$(printf '%s' "$newest" | jq -r '.status // ""')
970
- url=$(printf '%s' "$newest" | jq -r '.url // ""')
971
- now=$(date +%s)
972
-
973
- if [ "$state" != completed ]; then
974
- set_many_or_fail status running remote_synced_at "$now"
975
- 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
976
1364
  return 0
977
1365
  fi
978
-
979
- synced_id=$(hr_registry_get "$registry" "$branch" remote_run_id)
980
- # Case 1 — already applied. A record still waiting on this run's bundle is
981
- # re-checked: once it expires, the job can no longer take an answer.
982
- if [ "$id" = "$synced_id" ]; then
983
- case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
984
- paused/expired) ;;
985
- parked/*|park_loop/*|paused/*)
986
- bundle_state "$id"
987
- if [ "$BUNDLE_STATE" = expired ]; then
988
- sync_expired "$id" "$url" "$now"
989
- return 0
990
- fi
991
- ;;
992
- esac
993
- set_or_fail remote_synced_at "$now"
994
- 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
995
1368
  return 0
996
1369
  fi
997
1370
 
998
- # Case 2 — a newer run whose bundle has expired.
999
- bundle_state "$id"
1371
+ # Case 2 — its bundle has expired.
1372
+ bundle_state "$RS_RUN_ID"
1000
1373
  if [ "$BUNDLE_STATE" = expired ]; then
1001
- sync_expired "$id" "$url" "$now"
1374
+ RS_STATE=paused
1375
+ RS_PAUSE_REASON=expired
1376
+ RS_DETAIL=$(expired_line "$RS_RUN_ID")
1002
1377
  return 0
1003
1378
  fi
1004
1379
 
1005
- # Case 3 — a newer run with a bundle.
1380
+ # Case 3 — a bundle.
1006
1381
  if [ "$BUNDLE_STATE" = present ]; then
1007
1382
  hr_remote_names_var
1008
- download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$id") || {
1009
- echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
1010
- exit "$EXIT_USAGE"
1011
- }
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"
1012
1390
  status_file="$download/$HR_REMOTE_STATUS_FILE"
1013
1391
  if [ ! -f "$status_file" ]; then
1014
1392
  mkdir -p "$download" || { echo "remote-run.sh: cannot create '$download'" >&2; exit "$EXIT_USAGE"; }
1015
- 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"
1016
1394
  fi
1017
- status=$(hr_remote_status_get "$status_file" status) || status=""
1018
- case "$status" in
1395
+ RS_BUNDLE=1
1396
+ RS_STATE=$(hr_remote_status_get "$status_file" status) || RS_STATE=""
1397
+ case "$RS_STATE" in
1019
1398
  running|parked|park_loop|paused|completed|failed) ;;
1020
1399
  *)
1021
1400
  echo "remote-run.sh: refused, nothing written: the bundle in '$download' is unrecognised" >&2
1022
1401
  exit "$EXIT_REFUSED"
1023
1402
  ;;
1024
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"
1025
1491
  hr_remote_bundle_restore "$download" "$worktree" "$branch" mirror
1026
1492
  case $? in
1027
1493
  0) ;;
@@ -1036,44 +1502,33 @@ verb_sync() {
1036
1502
  || { echo "remote-run.sh: copying the run log to '$remote_log' failed" >&2; exit "$EXIT_USAGE"; }
1037
1503
  fi
1038
1504
  fi
1039
- reason=$(hr_remote_status_get "$status_file" pause_reason) || reason=""
1040
- detail=$(hr_remote_status_get "$status_file" detail) || detail=""
1041
- if [ "$status" = running ]; then
1042
- status=paused
1043
- reason=killed
1044
- detail="the job ended mid-run (its bundle still says running): $url"
1045
- fi
1505
+ status="$RS_STATE"
1506
+ reason="$RS_PAUSE_REASON"
1507
+ detail="$RS_DETAIL"
1046
1508
  [ -n "$detail" ] || detail="synced from $url"
1047
- resume_at=$(hr_remote_status_get "$status_file" usage_resume_at) || resume_at=""
1048
- cycles=$(hr_remote_status_get "$status_file" park_loop_cycles) || cycles=""
1049
- set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$resume_at" \
1050
- park_loop_cycles "$cycles" remote_run_id "$id" remote_run_url "$url" \
1051
- 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
1052
1518
  echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
1053
1519
  return 0
1054
1520
  fi
1055
1521
 
1056
1522
  # Case 4 — a newer run with no bundle, while some bundle exists.
1057
- local bundle_exists=0
1058
- if [ -n "$synced_id" ]; then
1059
- bundle_exists=1
1060
- else
1061
- for older in $(printf '%s' "$runs" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
1062
- if has_bundle "$older"; then bundle_exists=1; break; fi
1063
- done
1064
- fi
1065
- if [ "$bundle_exists" -eq 1 ]; then
1523
+ if [ "$RS_STATE" = paused ]; then
1066
1524
  set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
1067
- remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url" \
1068
- remote_synced_at "$now"
1525
+ remote_detail "$RS_DETAIL" remote_synced_at "$now"
1069
1526
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
1070
1527
  return 0
1071
1528
  fi
1072
1529
 
1073
1530
  # Case 5 — no bundle in any run.
1074
- set_many_or_fail status failed \
1075
- remote_detail "no run of $branch ever uploaded a state bundle; newest: $url" \
1076
- remote_synced_at "$now"
1531
+ set_many_or_fail status failed remote_detail "$RS_DETAIL" remote_synced_at "$now"
1077
1532
  echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
1078
1533
  }
1079
1534
 
@@ -1140,7 +1595,7 @@ verb_restore() {
1140
1595
  if [ "$PREV_RUN_STATE" = expired ]; then
1141
1596
  [ "$resume" != answer ] \
1142
1597
  || 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"
1143
- echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts and the clarification history it carried are lost; this job continues from the committed ledger"
1598
+ 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"
1144
1599
  elif [ -z "$id" ]; then
1145
1600
  [ "$resume" != answer ] \
1146
1601
  || restore_refuse "--resume answer, but no finished run of $branch carries a state bundle; nothing written"
@@ -1163,7 +1618,11 @@ verb_restore() {
1163
1618
  fi
1164
1619
  hr_remote_bundle_restore "$download" "$root" "$branch" job
1165
1620
  case $? in
1166
- 0) echo "remote-run.sh: restored the bundle of run $id into $root" ;;
1621
+ 0)
1622
+ echo "remote-run.sh: restored the bundle of run $id into $root"
1623
+ [ $((HR_REMOTE_PLANNING_PLACED + HR_REMOTE_PLANNING_KEPT)) -eq 0 ] \
1624
+ || echo "remote-run.sh: placed $HR_REMOTE_PLANNING_PLACED planning file(s) for $branch; kept $HR_REMOTE_PLANNING_KEPT the checkout already carries"
1625
+ ;;
1167
1626
  2) restore_refuse "the bundle in '$download' is unrecognised for $branch; nothing restored" ;;
1168
1627
  *) restore_fail "restoring '$download' into '$root' failed" ;;
1169
1628
  esac
@@ -1304,7 +1763,7 @@ ALL_RUNS_LISTED=0
1304
1763
  list_all_runs() {
1305
1764
  [ "$ALL_RUNS_LISTED" -eq 0 ] || return 0
1306
1765
  gh_call run list --workflow "$WORKFLOW_RUN_FILE" \
1307
- --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
1308
1767
  printf '%s' "$GH_OUT" | jq -e 'type == "array"' >/dev/null 2>&1 || {
1309
1768
  GH_ERR="its run list is not the expected JSON"
1310
1769
  return 1
@@ -1378,13 +1837,13 @@ RESUME_HINT="/autonomous-sdlc-harness:branch-resume"
1378
1837
  continue_redispatch() {
1379
1838
  local status_file="$1" engine_value
1380
1839
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
1381
- 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" "")."
1382
1841
  return 0
1383
1842
  fi
1384
1843
  remote_branch_stopped "$branch"
1385
1844
  case $? in
1386
1845
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
1387
- 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 ;;
1388
1847
  esac
1389
1848
  if ! max_chain_var; then
1390
1849
  notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer."
@@ -1401,19 +1860,19 @@ continue_redispatch() {
1401
1860
  return 0
1402
1861
  fi
1403
1862
  redispatch "$engine_value" "$NEXT_CHAIN" \
1404
- || 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")."
1405
1864
  }
1406
1865
 
1407
1866
  continue_wait_poller() {
1408
1867
  remote_branch_stopped "$branch"
1409
1868
  case $? in
1410
1869
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
1411
- 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 ;;
1412
1871
  esac
1413
1872
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
1414
1873
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
1415
1874
  else
1416
- 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" "")."
1417
1876
  fi
1418
1877
  }
1419
1878
 
@@ -1637,7 +2096,7 @@ poll_branch() {
1637
2096
  if [ "$failures" -ge "$((10#$POLL_MAX_FAILURES))" ] \
1638
2097
  || [ "$now" -gt "$((10#$at + 10#$POLL_GIVE_UP_MINUTES * 60))" ]; then
1639
2098
  poll_state_put "$branch" "$id" "$failures" 1
1640
- 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")."
1641
2100
  return 1
1642
2101
  fi
1643
2102
  poll_state_put "$branch" "$id" "$failures" ""
@@ -1694,7 +2153,7 @@ poll_recheck() {
1694
2153
  return 0
1695
2154
  fi
1696
2155
  for b in $POLL_WAITING; do
1697
- 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" "")."
1698
2157
  done
1699
2158
  }
1700
2159
 
@@ -1767,6 +2226,626 @@ verb_run_created_at() {
1767
2226
  printf '%s\n' "$epoch"
1768
2227
  }
1769
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
+
1770
2849
  case "$verb" in
1771
2850
  dispatch) verb_dispatch ;;
1772
2851
  pause) verb_pause ;;
@@ -1780,5 +2859,10 @@ case "$verb" in
1780
2859
  poll) verb_poll ;;
1781
2860
  pause-requested) verb_pause_requested ;;
1782
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 ;;
1783
2867
  esac
1784
2868
  exit "$EXIT_OK"