patchrelay 0.47.1 → 0.48.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.
- package/README.md +70 -353
- package/dist/agent-session-plan.js +30 -0
- package/dist/build-info.json +3 -3
- package/dist/db/issue-store.js +16 -2
- package/dist/db/migrations.js +10 -0
- package/dist/github-webhook-terminal-handler.js +7 -0
- package/dist/issue-class.js +31 -0
- package/dist/issue-session-events.js +12 -2
- package/dist/no-pr-completion-check.js +7 -0
- package/dist/orchestration-parent-wake.js +37 -0
- package/dist/prompting/patchrelay.js +158 -15
- package/dist/run-orchestrator.js +58 -2
- package/dist/tracked-issue-projector.js +1 -0
- package/dist/webhooks/comment-wake-handler.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,408 +1,125 @@
|
|
|
1
1
|
# PatchRelay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Self-hosted toolkit for shipping code with agents: a Linear-driven harness that runs Codex sessions inside your real repos, plus two GitHub-native services that review PRs and deliver them through a merge queue. Each component works on its own, and they communicate only through GitHub.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## The stack
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This repository ships **three independent services**. Install one, two, or all three.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- reactive CI repair and review fix loops
|
|
15
|
-
- queue repair runs triggered by Merge Steward evictions
|
|
16
|
-
- native Linear agent input forwarding into active runs
|
|
17
|
-
- read-only inspection and run reporting
|
|
9
|
+
| Service | Package | Role |
|
|
10
|
+
|-|-|-|
|
|
11
|
+
| [`patchrelay`](./) | `npm install -g patchrelay` | Linear-driven harness that runs Codex sessions inside your real repos. Fully autonomous on webhooks: implementation, review fix, CI repair, queue repair. |
|
|
12
|
+
| [`review-quill`](./packages/review-quill) | `npm install -g review-quill` | GitHub PR review bot. Reviews every merge-ready head from a real local checkout and posts a normal `APPROVE` / `REQUEST_CHANGES` review. |
|
|
13
|
+
| [`merge-steward`](./packages/merge-steward) | `npm install -g merge-steward` | Serial merge queue. Speculatively integrates approved PRs on top of the latest `main`, runs CI on the integrated SHA, and fast-forwards `main` only when that tested result is green. |
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
Common setups:
|
|
20
16
|
|
|
21
|
-
|
|
17
|
+
- **Full autonomy** — all three. PatchRelay implements from a Linear issue, review-quill reviews, merge-steward delivers. No human in the room.
|
|
18
|
+
- **Supervised delivery** — `review-quill` + `merge-steward` without PatchRelay, driven by your own agent (Claude Code, Cursor, Codex CLI, …). See [Use with your own agent](#use-with-your-own-agent).
|
|
19
|
+
- **Queue only** or **review only** — run either downstream service on its own if you already have the other half of the story.
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
- Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
|
|
25
|
-
- Keep deterministic workflow logic outside the model: context packaging, routing, run orchestration, worktree ownership, verification, and reporting.
|
|
26
|
-
- Choose the Codex approval and sandbox settings that match your risk tolerance.
|
|
27
|
-
- Let Linear drive the loop through delegation and native agent sessions.
|
|
28
|
-
- Let GitHub drive reactive loops through PR reviews and CI check events.
|
|
29
|
-
- Drop into the exact issue worktree and resume control manually when needed.
|
|
21
|
+
### What this buys you
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
- **PRs ship tested against the latest `main`.** The queue re-validates on the integrated SHA at admission time, and retries if `main` moves during validation. No more "green yesterday, broken today."
|
|
24
|
+
- **Many PR failures have mechanical fixes an agent can handle.** Requested changes like a rename, a missing null check, a new test; rebase on `main`; resolving a conflict surfaced by speculation; rerunning a flaky job. Both services publish structured failure reasons (inline review comments, failing check names, queue incidents) an agent can act on directly.
|
|
25
|
+
- **No prerequisites beyond GitHub.** A GitHub App, a webhook, and `npm install -g` per service.
|
|
32
26
|
|
|
33
|
-
|
|
27
|
+
## Use with your own agent
|
|
34
28
|
|
|
35
|
-
|
|
36
|
-
- maps issue events to the correct local project
|
|
37
|
-
- packages the right issue, repo, review, and failure context for each loop
|
|
38
|
-
- creates and reuses one durable worktree and branch per issue lifecycle
|
|
39
|
-
- starts Codex threads for implementation runs
|
|
40
|
-
- triggers reactive runs for CI failures, review feedback, and Merge Steward evictions
|
|
41
|
-
- opens and updates PRs for delegated implementation work
|
|
42
|
-
- marks its own PRs ready when implementation is complete
|
|
43
|
-
- can later repair a linked PR that was opened externally once the issue is delegated
|
|
44
|
-
- persists enough state to correlate the Linear issue, local workspace, run, and Codex thread
|
|
45
|
-
- reports progress back to Linear and forwards follow-up agent input into active runs
|
|
46
|
-
- exposes CLI and optional read-only inspection surfaces so operators can understand what happened
|
|
29
|
+
For supervised delivery — an agent you drive from Claude Code / Cursor / Codex iterating on PRs in real time — install the [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the companion marketplace:
|
|
47
30
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
PatchRelay works best when read as five layers with clear ownership:
|
|
53
|
-
|
|
54
|
-
- policy layer: repo workflow files (`IMPLEMENTATION_WORKFLOW.md`, `REVIEW_WORKFLOW.md`)
|
|
55
|
-
- coordination layer: issue claiming, run scheduling, retry budgets, and reconciliation
|
|
56
|
-
- execution layer: durable worktrees, Codex threads, and queued turn input delivery
|
|
57
|
-
- integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
|
|
58
|
-
- observability layer: CLI inspection, session status, and operator endpoints
|
|
59
|
-
|
|
60
|
-
That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
|
|
61
|
-
|
|
62
|
-
## Runtime Model
|
|
63
|
-
|
|
64
|
-
PatchRelay is designed for a local, operator-owned setup:
|
|
65
|
-
|
|
66
|
-
- PatchRelay service runs on your machine or server (default `127.0.0.1:8787`)
|
|
67
|
-
- Codex runs through `codex app-server`
|
|
68
|
-
- Linear is the control surface
|
|
69
|
-
- `patchrelay` CLI is the operator interface
|
|
70
|
-
- a reverse proxy exposes the Linear-facing and GitHub-facing webhook routes
|
|
71
|
-
|
|
72
|
-
Linux and Node.js `24+` are the intended runtime.
|
|
73
|
-
|
|
74
|
-
You will also need:
|
|
75
|
-
|
|
76
|
-
- `git`
|
|
77
|
-
- `codex`
|
|
78
|
-
- a Linear OAuth app for this PatchRelay deployment
|
|
79
|
-
- a Linear webhook secret
|
|
80
|
-
- a public HTTPS entrypoint such as Caddy, nginx, or a tunnel so Linear and GitHub can reach your PatchRelay webhooks
|
|
81
|
-
|
|
82
|
-
## How It Works
|
|
83
|
-
|
|
84
|
-
1. A human delegates PatchRelay on an issue to start automation.
|
|
85
|
-
2. PatchRelay verifies the webhook, routes the issue to the right local project, and packages the issue context for the first loop.
|
|
86
|
-
3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
|
|
87
|
-
4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
|
|
88
|
-
5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures and review fix on changes requested.
|
|
89
|
-
6. PatchRelay opens draft PRs while implementation is in progress and marks its own PR ready when implementation is complete.
|
|
90
|
-
7. Downstream automation reacts to GitHub truth: `reviewbot` reviews ready PRs with green CI, and Merge Steward admits ready PRs with green CI and approval into the merge queue.
|
|
91
|
-
8. If requested changes, red CI, or a merge-steward incident lands on a linked delegated PR, PatchRelay resumes work on that same PR branch.
|
|
92
|
-
9. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
|
|
93
|
-
|
|
94
|
-
### Undelegation And Re-delegation
|
|
95
|
-
|
|
96
|
-
Undelegation pauses PatchRelay authority. It does not erase PR truth.
|
|
97
|
-
|
|
98
|
-
- If there is no PR yet, the issue keeps its literal local-work state such as `delegated` or `implementing`, but PatchRelay becomes paused.
|
|
99
|
-
- If a PR already exists, the issue keeps its PR-backed state and PatchRelay becomes observer-only.
|
|
100
|
-
- Worktrees, branches, and PRs remain in place.
|
|
101
|
-
- PatchRelay still reflects GitHub review, CI, queue, merge, and close events while undelegated.
|
|
102
|
-
- PatchRelay does not enqueue implementation, review-fix, CI-repair, or queue-repair work again until the issue is delegated back.
|
|
103
|
-
- If someone opens a new PR for the issue while it is undelegated, PatchRelay can link that PR when the title, body, or branch name contains one unambiguous tracked issue key for the project.
|
|
104
|
-
|
|
105
|
-
Downstream services stay PR-centric:
|
|
106
|
-
|
|
107
|
-
- `review-quill` may still review a qualifying PR
|
|
108
|
-
- `merge-steward` may still queue or merge a qualifying PR
|
|
109
|
-
|
|
110
|
-
When the issue is delegated back to PatchRelay, it should resume from current truth:
|
|
111
|
-
|
|
112
|
-
- no PR: queue implementation
|
|
113
|
-
- PR with requested changes: queue review fix or branch upkeep
|
|
114
|
-
- PR with failing CI: queue CI repair
|
|
115
|
-
- PR with queue eviction/conflict: queue queue repair
|
|
116
|
-
- healthy open PR: keep waiting on review
|
|
117
|
-
- approved PR: keep waiting downstream
|
|
118
|
-
|
|
119
|
-
## Ownership Model
|
|
120
|
-
|
|
121
|
-
PatchRelay keeps ownership simple:
|
|
122
|
-
|
|
123
|
-
- workflow truth: the current factory state plus GitHub PR/review/CI facts
|
|
124
|
-
- runtime authority: whether PatchRelay may actively write or repair code right now
|
|
125
|
-
|
|
126
|
-
PatchRelay persists one explicit authority bit:
|
|
127
|
-
|
|
128
|
-
- `delegatedToPatchRelay`: whether PatchRelay may actively implement or repair code for the issue right now
|
|
129
|
-
|
|
130
|
-
Once a PR is linked to an issue, delegation decides whether PatchRelay may repair it. The PR may have been opened by PatchRelay, a human, or another external system.
|
|
131
|
-
|
|
132
|
-
That authority does not change just because:
|
|
133
|
-
|
|
134
|
-
- the issue is undelegated
|
|
135
|
-
- the PR becomes ready for review
|
|
136
|
-
- the PR is approved
|
|
137
|
-
- the PR enters or leaves the merge queue
|
|
138
|
-
|
|
139
|
-
## Factory State Machine
|
|
140
|
-
|
|
141
|
-
Each issue progresses through a factory state machine:
|
|
142
|
-
|
|
143
|
-
```text
|
|
144
|
-
delegated → preparing → implementing → pr_open → awaiting_review
|
|
145
|
-
→ changes_requested (review fix run) → back to implementing
|
|
146
|
-
→ repairing_ci (CI repair run) → back to pr_open
|
|
147
|
-
→ awaiting_queue → done (merged)
|
|
148
|
-
→ repairing_queue (queue repair run) → back to pr_open
|
|
149
|
-
→ escalated or failed (when retry budgets are exhausted)
|
|
31
|
+
```
|
|
32
|
+
/plugin marketplace add krasnoperov/patchrelay-agents
|
|
33
|
+
/plugin install ship-pr@patchrelay
|
|
150
34
|
```
|
|
151
35
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- `implementation` — initial coding work
|
|
155
|
-
- `review_fix` — address reviewer feedback
|
|
156
|
-
- `ci_repair` — fix failing CI checks
|
|
157
|
-
- `queue_repair` — fix merge queue failures
|
|
158
|
-
|
|
159
|
-
PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
|
|
160
|
-
|
|
161
|
-
The long-term runtime model is a small durable `IssueSession`:
|
|
162
|
-
|
|
163
|
-
- `idle`
|
|
164
|
-
- `running`
|
|
165
|
-
- `waiting_input`
|
|
166
|
-
- `done`
|
|
167
|
-
- `failed`
|
|
168
|
-
|
|
169
|
-
Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
|
|
170
|
-
|
|
171
|
-
`awaiting_input` is reserved for real human-needed situations:
|
|
172
|
-
|
|
173
|
-
- a completion check asked a question
|
|
174
|
-
- an operator explicitly stopped the run and wants a next decision
|
|
175
|
-
- a reply is required before PatchRelay can continue
|
|
176
|
-
|
|
177
|
-
Undelegated local work should stay in its literal workflow state and show a paused waiting reason instead.
|
|
178
|
-
|
|
179
|
-
## Restart And Reconciliation
|
|
180
|
-
|
|
181
|
-
PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
|
|
182
|
-
|
|
183
|
-
After a restart, the service can answer:
|
|
184
|
-
|
|
185
|
-
- which issue owns each active worktree
|
|
186
|
-
- which run was active or queued
|
|
187
|
-
- which Codex thread and turn belong to that work
|
|
188
|
-
- whether the issue is still eligible to continue
|
|
189
|
-
- whether the run should resume, hand off, or fail back to a human state
|
|
190
|
-
|
|
191
|
-
This is why PatchRelay keeps a durable `issues` and `runs` table alongside Codex thread history and Linear state. The goal is not to duplicate the model transcript. The goal is to make automation restartable, inspectable, and recoverable when the process or machine is interrupted.
|
|
192
|
-
|
|
193
|
-
## Workflow Files
|
|
194
|
-
|
|
195
|
-
PatchRelay uses repo-local workflow files as prompts for Codex runs:
|
|
196
|
-
|
|
197
|
-
- `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
|
|
198
|
-
- `REVIEW_WORKFLOW.md` — used for review fix runs
|
|
199
|
-
|
|
200
|
-
These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
|
|
201
|
-
|
|
202
|
-
The built-in PatchRelay prompt scaffold lives in `src/prompting/patchrelay.ts`. It is intentionally small: task objective, scope discipline, reactive repair context, workflow guidance, and publication contract. Installation-level and repo-level prompt config can add one extra instructions file or replace a small set of policy sections. See [`docs/prompting.md`](./docs/prompting.md).
|
|
203
|
-
|
|
204
|
-
## Knowledge And Validation Surfaces
|
|
205
|
-
|
|
206
|
-
PatchRelay works best when repository guidance follows progressive disclosure:
|
|
207
|
-
|
|
208
|
-
- keep the root entrypoints short and navigational
|
|
209
|
-
- treat deeper `docs/` content as the durable system of record
|
|
210
|
-
- capture architecture, workflow, and product decisions in versioned files instead of chat history or operator memory
|
|
211
|
-
|
|
212
|
-
PatchRelay should also help agents validate their own work inside the issue loop:
|
|
213
|
-
|
|
214
|
-
- package the smallest useful context for the current run instead of replaying ever-growing transcript history
|
|
215
|
-
- preserve high-signal failure evidence such as review feedback, failing checks, and queue incidents
|
|
216
|
-
- make repo-local validation surfaces legible per worktree so the next run can see what passed, what failed, and what needs repair
|
|
217
|
-
|
|
218
|
-
Keeping those knowledge and validation surfaces clean is part of the harness, not optional documentation polish.
|
|
219
|
-
|
|
220
|
-
## Access Control
|
|
221
|
-
|
|
222
|
-
PatchRelay reacts only for issues that route to a configured project.
|
|
223
|
-
|
|
224
|
-
- use `linear_team_ids`, `issue_key_prefixes`, and optional labels to keep unrelated or public boards out of scope
|
|
225
|
-
- in the normal setup, anyone with access to the routed Linear project can delegate work to the PatchRelay app
|
|
226
|
-
- use `trusted_actors` only when a project needs a narrower allowlist inside Linear
|
|
36
|
+
`ship-pr` teaches the agent to block on `review-quill pr status --wait` and `merge-steward pr status --wait`, read structured failure reasons on exit `2`, fix the code, push, and re-enter the wait. No polling loop, no LLM-judged "is it done yet?". See [patchrelay-agents](https://github.com/krasnoperov/patchrelay-agents) for more.
|
|
227
37
|
|
|
228
|
-
|
|
38
|
+
## Quick start (PatchRelay harness)
|
|
229
39
|
|
|
230
|
-
|
|
40
|
+
Prerequisites:
|
|
231
41
|
|
|
232
|
-
|
|
42
|
+
- Linux with shell access, Node.js `24+`
|
|
43
|
+
- `git` and `codex` (authenticated for the same user that will run PatchRelay)
|
|
44
|
+
- a Linear OAuth app and webhook secret
|
|
45
|
+
- a public HTTPS entrypoint (Caddy, nginx, tunnel) so Linear and GitHub can reach your webhooks
|
|
233
46
|
|
|
234
47
|
```bash
|
|
235
48
|
npm install -g patchrelay
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
### 2. Bootstrap config
|
|
239
|
-
|
|
240
|
-
```bash
|
|
241
49
|
patchrelay init https://patchrelay.example.com
|
|
242
50
|
```
|
|
243
51
|
|
|
244
|
-
`
|
|
245
|
-
|
|
246
|
-
It creates the local config, env file, and system service units:
|
|
247
|
-
|
|
248
|
-
- `~/.config/patchrelay/runtime.env`
|
|
249
|
-
- `~/.config/patchrelay/service.env`
|
|
250
|
-
- `~/.config/patchrelay/patchrelay.json`
|
|
251
|
-
- `/etc/systemd/system/patchrelay.service`
|
|
252
|
-
|
|
253
|
-
The generated `patchrelay.json` is intentionally minimal, and `patchrelay init` prints the webhook URL, OAuth callback URL, and the Linear app values you need next.
|
|
254
|
-
|
|
255
|
-
### 3. Configure access
|
|
256
|
-
|
|
257
|
-
Edit `~/.config/patchrelay/service.env` and fill in only the Linear OAuth client values. Keep the generated webhook secret and token-encryption key:
|
|
258
|
-
|
|
259
|
-
```bash
|
|
260
|
-
LINEAR_WEBHOOK_SECRET=generated-by-patchrelay-init
|
|
261
|
-
PATCHRELAY_TOKEN_ENCRYPTION_KEY=generated-by-patchrelay-init
|
|
262
|
-
LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
|
|
263
|
-
LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
Keep service secrets in `service.env`. `runtime.env` is for non-secret overrides such as `PATCHRELAY_DB_PATH` or `PATCHRELAY_LOG_FILE`. Everyday local inspection commands do not require exporting these values in your shell.
|
|
267
|
-
|
|
268
|
-
### 4. Connect PatchRelay to Linear
|
|
269
|
-
|
|
270
|
-
Connect PatchRelay to one Linear workspace:
|
|
52
|
+
`init` writes the local config, env files, and systemd unit. Edit `~/.config/patchrelay/service.env` to fill in the Linear OAuth client id and secret (the webhook secret and token-encryption key are generated for you). Then:
|
|
271
53
|
|
|
272
54
|
```bash
|
|
273
|
-
patchrelay linear connect
|
|
274
|
-
patchrelay linear sync
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
### 5. Link a GitHub repo
|
|
280
|
-
|
|
281
|
-
Link repos by GitHub identity, not by local path:
|
|
282
|
-
|
|
283
|
-
```bash
|
|
284
|
-
patchrelay repo link krasnoperov/usertold --workspace usertold --team USE
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
PatchRelay treats the GitHub repo as the source of truth. It reuses an existing local clone under the managed repo root when `origin` already matches, or clones it automatically when missing. Use `--path <path>` only when you want a non-default local location.
|
|
288
|
-
|
|
289
|
-
The generated `~/.config/patchrelay/patchrelay.json` stays machine-level service config. Repo links should be created with the CLI, not by hand-editing the file.
|
|
290
|
-
|
|
291
|
-
`patchrelay repo link` is idempotent:
|
|
292
|
-
|
|
293
|
-
- it creates or updates the linked repo entry
|
|
294
|
-
- it refreshes the selected Linear workspace catalog before resolving teams/projects
|
|
295
|
-
- it reloads the service when it can
|
|
296
|
-
- if workflow files or secrets are still missing, it tells you exactly what to fix and can be rerun safely
|
|
297
|
-
|
|
298
|
-
### 6. Add workflow docs to the repo
|
|
299
|
-
|
|
300
|
-
PatchRelay looks for:
|
|
301
|
-
|
|
302
|
-
```text
|
|
303
|
-
IMPLEMENTATION_WORKFLOW.md
|
|
304
|
-
REVIEW_WORKFLOW.md
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
These files define how the agent should work in that repo.
|
|
308
|
-
|
|
309
|
-
### 7. Validate
|
|
310
|
-
|
|
311
|
-
```bash
|
|
312
|
-
patchrelay doctor
|
|
55
|
+
patchrelay linear connect # one-time Linear OAuth
|
|
56
|
+
patchrelay linear sync # cache teams/projects
|
|
57
|
+
patchrelay repo link krasnoperov/usertold \
|
|
58
|
+
--workspace usertold --team USE # link a GitHub repo
|
|
59
|
+
patchrelay doctor # validate
|
|
313
60
|
patchrelay service status
|
|
314
61
|
patchrelay dashboard
|
|
315
62
|
```
|
|
316
63
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
```bash
|
|
320
|
-
patchrelay linear list
|
|
321
|
-
patchrelay repo list
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
If you later add another local repo from the same workspace, run `patchrelay repo link <owner/repo> --workspace <workspace> --team <team>` again. PatchRelay reuses the existing workspace installation instead of opening a new OAuth flow.
|
|
325
|
-
|
|
326
|
-
Important:
|
|
64
|
+
Each repo needs two workflow files that act as agent prompts:
|
|
327
65
|
|
|
328
|
-
-
|
|
329
|
-
- `
|
|
330
|
-
- For ingress, OAuth app setup, and webhook details, use the self-hosting docs.
|
|
66
|
+
- `IMPLEMENTATION_WORKFLOW.md` — implementation, CI repair, queue repair runs
|
|
67
|
+
- `REVIEW_WORKFLOW.md` — review fix runs
|
|
331
68
|
|
|
332
|
-
|
|
69
|
+
Keep them short, action-oriented, human-authored. See [prompting.md](./docs/prompting.md) for how the built-in scaffold composes them with the rest of the prompt.
|
|
333
70
|
|
|
334
|
-
|
|
335
|
-
2. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches an implementation run.
|
|
336
|
-
3. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
|
|
337
|
-
4. GitHub webhooks automatically trigger CI repair, review fix, or merge queue repair runs when needed.
|
|
338
|
-
5. Watch progress from the terminal or open the same worktree and take over manually.
|
|
71
|
+
Full install, ingress, and GitHub/Linear app setup: [self-hosting.md](./docs/self-hosting.md). Daily ops and CLI cheatsheet: [operator-guide.md](./docs/operator-guide.md).
|
|
339
72
|
|
|
340
|
-
|
|
73
|
+
## How it works
|
|
341
74
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
- `patchrelay issue retry APP-123`
|
|
349
|
-
- `patchrelay service restart`
|
|
350
|
-
- `patchrelay service logs --lines 100`
|
|
75
|
+
1. A human delegates an issue to the PatchRelay Linear app.
|
|
76
|
+
2. PatchRelay verifies the webhook, routes the issue to the right local repo, prepares a durable worktree, and launches an implementation run through `codex app-server`.
|
|
77
|
+
3. PatchRelay persists thread ids, run state, and observations so work stays inspectable and restartable.
|
|
78
|
+
4. GitHub webhooks drive reactive repair loops — CI repair on check failures, review fix on requested changes, queue repair on merge-steward evictions.
|
|
79
|
+
5. `review-quill` reviews ready PRs; `merge-steward` admits approved, green PRs and delivers them by speculative integration.
|
|
80
|
+
6. An operator can take over inside the same worktree at any time.
|
|
351
81
|
|
|
352
|
-
|
|
353
|
-
active work, waiting reason, worktree handoff, and retry controls.
|
|
82
|
+
Architecture and failure taxonomy: [architecture.md](./docs/architecture.md). Downstream delivery: [merge-queue.md](./docs/merge-queue.md).
|
|
354
83
|
|
|
355
|
-
|
|
84
|
+
## Downstream services
|
|
356
85
|
|
|
357
|
-
|
|
86
|
+
Two separate services handle review and delivery. Both are independent, GitHub-native, and usable without PatchRelay.
|
|
358
87
|
|
|
359
|
-
|
|
360
|
-
2. `patchrelay issue show APP-123` or `patchrelay issue watch APP-123` to inspect one issue in more detail.
|
|
361
|
-
3. `patchrelay issue open APP-123` to take over inside the exact worktree and continue from the same issue context.
|
|
362
|
-
4. `patchrelay service logs --lines 100` if the problem looks like webhook intake, Codex startup, or service runtime failure.
|
|
88
|
+
### review-quill
|
|
363
89
|
|
|
364
|
-
|
|
90
|
+
Watches PRs and posts ordinary GitHub reviews from a real local checkout of the PR head. By default reviews as soon as the head updates; can optionally wait for configured checks to go green first.
|
|
365
91
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
- which run is active or queued
|
|
372
|
-
- which Codex thread owns the current work
|
|
373
|
-
- what the agent said
|
|
374
|
-
- which commands it ran
|
|
375
|
-
- which files it changed
|
|
376
|
-
- whether the run completed, failed, or needs handoff
|
|
377
|
-
|
|
378
|
-
## Merge Steward
|
|
92
|
+
```bash
|
|
93
|
+
review-quill init https://review.example.com
|
|
94
|
+
review-quill repo attach owner/repo
|
|
95
|
+
review-quill doctor --repo repo
|
|
96
|
+
```
|
|
379
97
|
|
|
380
|
-
[
|
|
98
|
+
See the [review-quill package README](./packages/review-quill/README.md) for the pitch and quick start, or [docs/review-quill.md](./docs/review-quill.md) for the full operator reference.
|
|
381
99
|
|
|
382
|
-
|
|
100
|
+
### merge-steward
|
|
383
101
|
|
|
384
|
-
|
|
102
|
+
Serial merge queue with speculative integration. Rebases each approved PR onto the current `main`, runs CI on the integrated SHA, and fast-forwards `main` only when that tested result is green. Evictions produce a durable incident and a GitHub check run — the signal an agent uses to trigger a repair.
|
|
385
103
|
|
|
386
104
|
```bash
|
|
387
105
|
merge-steward init https://queue.example.com
|
|
388
|
-
merge-steward attach
|
|
389
|
-
merge-steward doctor --repo
|
|
106
|
+
merge-steward attach owner/repo --base-branch main
|
|
107
|
+
merge-steward doctor --repo repo
|
|
390
108
|
merge-steward service status
|
|
391
|
-
merge-steward queue status --repo app
|
|
392
109
|
```
|
|
393
110
|
|
|
394
|
-
See [
|
|
111
|
+
See the [merge-steward package README](./packages/merge-steward/README.md) for the pitch and quick start, [docs/merge-steward.md](./docs/merge-steward.md) for the full operator reference, or [docs/merge-queue.md](./docs/merge-queue.md) for the two-service overview.
|
|
395
112
|
|
|
396
113
|
## Docs
|
|
397
114
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
- [
|
|
401
|
-
- [
|
|
402
|
-
- [
|
|
403
|
-
- [
|
|
404
|
-
- [
|
|
405
|
-
- [
|
|
115
|
+
- [Self-hosting and deployment](./docs/self-hosting.md) — install, ingress, OAuth and GitHub App setup
|
|
116
|
+
- [Architecture](./docs/architecture.md) — components, ownership, state machine, failure taxonomy
|
|
117
|
+
- [Operator guide](./docs/operator-guide.md) — daily loop, CLI cheatsheet, troubleshooting
|
|
118
|
+
- [Merge queue](./docs/merge-queue.md) — the two-service delivery story
|
|
119
|
+
- [Prompting](./docs/prompting.md) — how workflow files and the built-in scaffold compose
|
|
120
|
+
- [Secrets](./docs/secrets.md) — systemd credentials, resolution order
|
|
121
|
+
- [review-quill reference](./docs/review-quill.md) · [merge-steward reference](./docs/merge-steward.md)
|
|
122
|
+
- [Design docs](./docs/design-docs/index.md) · [Core beliefs](./docs/design-docs/core-beliefs.md)
|
|
406
123
|
- [Security policy](./SECURITY.md)
|
|
407
124
|
|
|
408
125
|
## Status
|
|
@@ -19,6 +19,14 @@ function implementationPlan() {
|
|
|
19
19
|
{ content: "Merge", status: "pending" },
|
|
20
20
|
];
|
|
21
21
|
}
|
|
22
|
+
function orchestrationPlan() {
|
|
23
|
+
return [
|
|
24
|
+
{ content: "Review umbrella goal and child set", status: "pending" },
|
|
25
|
+
{ content: "Wait for or inspect child progress", status: "pending" },
|
|
26
|
+
{ content: "Audit delivered outcome", status: "pending" },
|
|
27
|
+
{ content: "Close umbrella or create follow-up work", status: "pending" },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
22
30
|
function reviewFixPlan() {
|
|
23
31
|
return [
|
|
24
32
|
{ content: "Prepare workspace", status: "completed" },
|
|
@@ -95,6 +103,27 @@ function resolvePlanRunType(params) {
|
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
export function buildAgentSessionPlan(params) {
|
|
106
|
+
if (params.issueClass === "orchestration") {
|
|
107
|
+
switch (params.factoryState) {
|
|
108
|
+
case "done":
|
|
109
|
+
return setStatuses(orchestrationPlan(), ["completed", "completed", "completed", "completed"]);
|
|
110
|
+
case "awaiting_input":
|
|
111
|
+
case "failed":
|
|
112
|
+
case "escalated":
|
|
113
|
+
return setStatuses(orchestrationPlan(), ["completed", "completed", "completed", "inProgress"]);
|
|
114
|
+
case "implementing":
|
|
115
|
+
case "changes_requested":
|
|
116
|
+
case "repairing_ci":
|
|
117
|
+
case "repairing_queue":
|
|
118
|
+
return setStatuses(orchestrationPlan(), ["completed", "inProgress", "pending", "pending"]);
|
|
119
|
+
case "pr_open":
|
|
120
|
+
case "awaiting_queue":
|
|
121
|
+
return setStatuses(orchestrationPlan(), ["completed", "completed", "inProgress", "pending"]);
|
|
122
|
+
case "delegated":
|
|
123
|
+
default:
|
|
124
|
+
return setStatuses(orchestrationPlan(), ["inProgress", "pending", "pending", "pending"]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
98
127
|
const runType = resolvePlanRunType(params);
|
|
99
128
|
switch (params.factoryState) {
|
|
100
129
|
case "delegated":
|
|
@@ -151,6 +180,7 @@ export function buildAgentSessionPlanForIssue(issue, options) {
|
|
|
151
180
|
factoryState: issue.factoryState,
|
|
152
181
|
ciRepairAttempts: issue.ciRepairAttempts,
|
|
153
182
|
queueRepairAttempts: issue.queueRepairAttempts,
|
|
183
|
+
...(issue.issueClass ? { issueClass: issue.issueClass } : {}),
|
|
154
184
|
...(issue.pendingRunType ? { pendingRunType: issue.pendingRunType } : {}),
|
|
155
185
|
...(options?.activeRunType ? { activeRunType: options.activeRunType } : {}),
|
|
156
186
|
});
|
package/dist/build-info.json
CHANGED
package/dist/db/issue-store.js
CHANGED
|
@@ -20,6 +20,14 @@ export class IssueStore {
|
|
|
20
20
|
sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
|
|
21
21
|
values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
|
|
22
22
|
}
|
|
23
|
+
if (params.issueClass !== undefined) {
|
|
24
|
+
sets.push("issue_class = @issueClass");
|
|
25
|
+
values.issueClass = params.issueClass;
|
|
26
|
+
}
|
|
27
|
+
if (params.issueClassSource !== undefined) {
|
|
28
|
+
sets.push("issue_class_source = @issueClassSource");
|
|
29
|
+
values.issueClassSource = params.issueClassSource;
|
|
30
|
+
}
|
|
23
31
|
if (params.issueKey !== undefined) {
|
|
24
32
|
sets.push("issue_key = COALESCE(@issueKey, issue_key)");
|
|
25
33
|
values.issueKey = params.issueKey;
|
|
@@ -221,7 +229,7 @@ export class IssueStore {
|
|
|
221
229
|
else {
|
|
222
230
|
this.connection.prepare(`
|
|
223
231
|
INSERT INTO issues (
|
|
224
|
-
project_id, linear_issue_id, delegated_to_patchrelay, issue_key, title, description, url,
|
|
232
|
+
project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, issue_key, title, description, url,
|
|
225
233
|
priority, estimate,
|
|
226
234
|
current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
|
|
227
235
|
branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
|
|
@@ -234,7 +242,7 @@ export class IssueStore {
|
|
|
234
242
|
ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
|
|
235
243
|
updated_at
|
|
236
244
|
) VALUES (
|
|
237
|
-
@projectId, @linearIssueId, @delegatedToPatchRelay, @issueKey, @title, @description, @url,
|
|
245
|
+
@projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @issueKey, @title, @description, @url,
|
|
238
246
|
@priority, @estimate,
|
|
239
247
|
@currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
240
248
|
@branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
|
|
@@ -251,6 +259,8 @@ export class IssueStore {
|
|
|
251
259
|
projectId: params.projectId,
|
|
252
260
|
linearIssueId: params.linearIssueId,
|
|
253
261
|
delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
|
|
262
|
+
issueClass: params.issueClass ?? null,
|
|
263
|
+
issueClassSource: params.issueClassSource ?? null,
|
|
254
264
|
issueKey: params.issueKey ?? null,
|
|
255
265
|
title: params.title ?? null,
|
|
256
266
|
description: params.description ?? null,
|
|
@@ -479,6 +489,10 @@ export function mapIssueRow(row) {
|
|
|
479
489
|
projectId: String(row.project_id),
|
|
480
490
|
linearIssueId: String(row.linear_issue_id),
|
|
481
491
|
delegatedToPatchRelay: Number(row.delegated_to_patchrelay ?? 1) !== 0,
|
|
492
|
+
...(row.issue_class !== null && row.issue_class !== undefined ? { issueClass: String(row.issue_class) } : {}),
|
|
493
|
+
...(row.issue_class_source !== null && row.issue_class_source !== undefined
|
|
494
|
+
? { issueClassSource: String(row.issue_class_source) }
|
|
495
|
+
: {}),
|
|
482
496
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
483
497
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
484
498
|
...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
|
package/dist/db/migrations.js
CHANGED
|
@@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
4
4
|
project_id TEXT NOT NULL,
|
|
5
5
|
linear_issue_id TEXT NOT NULL,
|
|
6
6
|
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
7
|
+
issue_class TEXT,
|
|
8
|
+
issue_class_source TEXT,
|
|
7
9
|
issue_key TEXT,
|
|
8
10
|
title TEXT,
|
|
9
11
|
url TEXT,
|
|
@@ -242,6 +244,8 @@ export function runPatchRelayMigrations(connection) {
|
|
|
242
244
|
// Clean up stale dedupe-only webhook records (no payload, never processable)
|
|
243
245
|
connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
|
|
244
246
|
addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
|
|
247
|
+
addColumnIfMissing(connection, "issues", "issue_class", "TEXT");
|
|
248
|
+
addColumnIfMissing(connection, "issues", "issue_class_source", "TEXT");
|
|
245
249
|
// Add pending_merge_prep column for merge queue stewardship
|
|
246
250
|
addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
|
|
247
251
|
// Add merge_prep_attempts for retry budget / escalation
|
|
@@ -324,6 +328,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
324
328
|
project_id TEXT NOT NULL,
|
|
325
329
|
linear_issue_id TEXT NOT NULL,
|
|
326
330
|
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
331
|
+
issue_class TEXT,
|
|
332
|
+
issue_class_source TEXT,
|
|
327
333
|
issue_key TEXT,
|
|
328
334
|
title TEXT,
|
|
329
335
|
description TEXT,
|
|
@@ -382,6 +388,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
382
388
|
project_id,
|
|
383
389
|
linear_issue_id,
|
|
384
390
|
delegated_to_patchrelay,
|
|
391
|
+
issue_class,
|
|
392
|
+
issue_class_source,
|
|
385
393
|
issue_key,
|
|
386
394
|
title,
|
|
387
395
|
description,
|
|
@@ -438,6 +446,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
438
446
|
project_id,
|
|
439
447
|
linear_issue_id,
|
|
440
448
|
COALESCE(delegated_to_patchrelay, 1),
|
|
449
|
+
issue_class,
|
|
450
|
+
issue_class_source,
|
|
441
451
|
issue_key,
|
|
442
452
|
title,
|
|
443
453
|
description,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
|
|
2
2
|
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
3
3
|
import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
|
|
4
|
+
import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
|
|
4
5
|
export async function handleGitHubTerminalPrEvent(params) {
|
|
5
6
|
const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
|
|
6
7
|
const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
|
|
@@ -66,6 +67,12 @@ export async function handleGitHubTerminalPrEvent(params) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
if (event.triggerEvent === "pr_merged") {
|
|
70
|
+
wakeOrchestrationParentsForChildEvent({
|
|
71
|
+
db,
|
|
72
|
+
child: updatedIssue,
|
|
73
|
+
eventType: "child_delivered",
|
|
74
|
+
enqueueIssue,
|
|
75
|
+
});
|
|
69
76
|
await completeLinearIssueAfterMerge(params, updatedIssue);
|
|
70
77
|
}
|
|
71
78
|
void syncGitHubLinearSession({
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function normalizeText(value) {
|
|
2
|
+
return value?.trim().toLowerCase() ?? "";
|
|
3
|
+
}
|
|
4
|
+
function looksLikeUmbrellaText(issue) {
|
|
5
|
+
const haystack = `${normalizeText(issue.title)}\n${normalizeText(issue.description)}`;
|
|
6
|
+
if (!haystack.trim())
|
|
7
|
+
return false;
|
|
8
|
+
return [
|
|
9
|
+
"umbrella",
|
|
10
|
+
"tracker",
|
|
11
|
+
"tracking",
|
|
12
|
+
"rollout",
|
|
13
|
+
"migration",
|
|
14
|
+
"convergence",
|
|
15
|
+
"audit",
|
|
16
|
+
"follow-up issues",
|
|
17
|
+
"planning/specification issue only",
|
|
18
|
+
].some((token) => haystack.includes(token));
|
|
19
|
+
}
|
|
20
|
+
export function classifyIssue(params) {
|
|
21
|
+
if (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration") {
|
|
22
|
+
return { issueClass: params.issue.issueClass, issueClassSource: "explicit" };
|
|
23
|
+
}
|
|
24
|
+
if (params.trackedDependentCount > 0) {
|
|
25
|
+
return { issueClass: "orchestration", issueClassSource: "hierarchy" };
|
|
26
|
+
}
|
|
27
|
+
if (looksLikeUmbrellaText(params.issue)) {
|
|
28
|
+
return { issueClass: "orchestration", issueClassSource: "heuristic" };
|
|
29
|
+
}
|
|
30
|
+
return { issueClass: "implementation", issueClassSource: "heuristic" };
|
|
31
|
+
}
|
|
@@ -52,7 +52,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
52
52
|
case "delegated":
|
|
53
53
|
if (!runType) {
|
|
54
54
|
runType = "implementation";
|
|
55
|
-
wakeReason = "delegated";
|
|
55
|
+
wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
|
|
56
56
|
}
|
|
57
57
|
if (payload?.promptContext !== undefined) {
|
|
58
58
|
context.promptContext = payload.promptContext;
|
|
@@ -61,6 +61,16 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
61
61
|
context.promptBody = payload.promptBody;
|
|
62
62
|
}
|
|
63
63
|
break;
|
|
64
|
+
case "child_changed":
|
|
65
|
+
case "child_delivered":
|
|
66
|
+
case "child_regressed":
|
|
67
|
+
if (!runType) {
|
|
68
|
+
runType = "implementation";
|
|
69
|
+
wakeReason = event.eventType;
|
|
70
|
+
}
|
|
71
|
+
Object.assign(context, payload ?? {});
|
|
72
|
+
resumeThread = true;
|
|
73
|
+
break;
|
|
64
74
|
case "direct_reply": {
|
|
65
75
|
if (!runType) {
|
|
66
76
|
runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
@@ -98,7 +108,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
98
108
|
case "operator_prompt": {
|
|
99
109
|
if (!runType) {
|
|
100
110
|
runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
101
|
-
wakeReason = event.eventType;
|
|
111
|
+
wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
|
|
102
112
|
}
|
|
103
113
|
const text = typeof payload?.text === "string"
|
|
104
114
|
? payload.text
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
|
|
2
|
+
import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
|
|
2
3
|
function shouldContinueForUnpublishedLocalChanges(message) {
|
|
3
4
|
const normalized = message.trim().toLowerCase();
|
|
4
5
|
if (!normalized)
|
|
@@ -206,6 +207,12 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
206
207
|
detail: completionCheck.summary,
|
|
207
208
|
activity: buildCompletionCheckActivity("done", completionCheck),
|
|
208
209
|
});
|
|
210
|
+
const doneIssue = params.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;
|
|
211
|
+
wakeOrchestrationParentsForChildEvent({
|
|
212
|
+
db: params.db,
|
|
213
|
+
child: doneIssue,
|
|
214
|
+
eventType: "child_delivered",
|
|
215
|
+
});
|
|
209
216
|
return;
|
|
210
217
|
}
|
|
211
218
|
const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { classifyIssue } from "./issue-class.js";
|
|
2
|
+
export function wakeOrchestrationParentsForChildEvent(params) {
|
|
3
|
+
const parentIds = [];
|
|
4
|
+
for (const blocker of params.db.issues.listIssueDependencies(params.child.projectId, params.child.linearIssueId)) {
|
|
5
|
+
const parent = params.db.issues.getIssue(params.child.projectId, blocker.blockerLinearIssueId);
|
|
6
|
+
if (!parent || !parent.delegatedToPatchRelay) {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
const classification = classifyIssue({
|
|
10
|
+
issue: parent,
|
|
11
|
+
trackedDependentCount: params.db.issues.listDependents(parent.projectId, parent.linearIssueId).length,
|
|
12
|
+
});
|
|
13
|
+
if (classification.issueClass !== "orchestration") {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(parent.projectId, parent.linearIssueId, {
|
|
17
|
+
projectId: parent.projectId,
|
|
18
|
+
linearIssueId: parent.linearIssueId,
|
|
19
|
+
eventType: params.eventType,
|
|
20
|
+
eventJson: JSON.stringify({
|
|
21
|
+
childIssueId: params.child.linearIssueId,
|
|
22
|
+
...(params.child.issueKey ? { childIssueKey: params.child.issueKey } : {}),
|
|
23
|
+
...(params.child.title ? { childTitle: params.child.title } : {}),
|
|
24
|
+
factoryState: params.child.factoryState,
|
|
25
|
+
...(params.child.currentLinearState ? { currentLinearState: params.child.currentLinearState } : {}),
|
|
26
|
+
...(params.child.prNumber !== undefined ? { prNumber: params.child.prNumber } : {}),
|
|
27
|
+
...(params.child.prState ? { prState: params.child.prState } : {}),
|
|
28
|
+
}),
|
|
29
|
+
dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.child.prState ?? "no-pr"}`,
|
|
30
|
+
});
|
|
31
|
+
if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
|
|
32
|
+
params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
|
|
33
|
+
}
|
|
34
|
+
parentIds.push(parent.linearIssueId);
|
|
35
|
+
}
|
|
36
|
+
return parentIds;
|
|
37
|
+
}
|
|
@@ -65,7 +65,76 @@ function buildTaskObjective(issue) {
|
|
|
65
65
|
...(intro ? ["", intro] : []),
|
|
66
66
|
].join("\n");
|
|
67
67
|
}
|
|
68
|
-
function
|
|
68
|
+
function summarizeRelationEntries(entries, options) {
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
return options?.emptyText ? [options.emptyText] : [];
|
|
71
|
+
}
|
|
72
|
+
const maxItems = options?.maxItems ?? 5;
|
|
73
|
+
const lines = entries.slice(0, maxItems).map((entry) => {
|
|
74
|
+
const issueRef = typeof entry.issueKey === "string" && entry.issueKey.trim()
|
|
75
|
+
? entry.issueKey.trim()
|
|
76
|
+
: typeof entry.linearIssueId === "string" && entry.linearIssueId.trim()
|
|
77
|
+
? entry.linearIssueId.trim()
|
|
78
|
+
: "unknown issue";
|
|
79
|
+
const title = typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : undefined;
|
|
80
|
+
const stateName = typeof entry.stateName === "string" && entry.stateName.trim()
|
|
81
|
+
? entry.stateName.trim()
|
|
82
|
+
: typeof entry.currentLinearState === "string" && entry.currentLinearState.trim()
|
|
83
|
+
? entry.currentLinearState.trim()
|
|
84
|
+
: undefined;
|
|
85
|
+
const factoryState = typeof entry.factoryState === "string" && entry.factoryState.trim() ? entry.factoryState.trim() : undefined;
|
|
86
|
+
const delegated = typeof entry.delegatedToPatchRelay === "boolean"
|
|
87
|
+
? (entry.delegatedToPatchRelay ? "delegated" : "not delegated")
|
|
88
|
+
: undefined;
|
|
89
|
+
const openPr = typeof entry.hasOpenPr === "boolean"
|
|
90
|
+
? (entry.hasOpenPr ? "open PR" : "no open PR")
|
|
91
|
+
: undefined;
|
|
92
|
+
return [
|
|
93
|
+
`- ${issueRef}`,
|
|
94
|
+
title ? `: ${title}` : "",
|
|
95
|
+
[stateName, factoryState, delegated, openPr].filter(Boolean).length > 0
|
|
96
|
+
? ` (${[stateName, factoryState, delegated, openPr].filter(Boolean).join("; ")})`
|
|
97
|
+
: "",
|
|
98
|
+
].join("");
|
|
99
|
+
});
|
|
100
|
+
if (entries.length > maxItems) {
|
|
101
|
+
lines.push(`- ...and ${entries.length - maxItems} more`);
|
|
102
|
+
}
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
function buildCoordinationGuidance(context) {
|
|
106
|
+
const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
|
|
107
|
+
? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
108
|
+
: [];
|
|
109
|
+
const trackedDependents = Array.isArray(context?.trackedDependents)
|
|
110
|
+
? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
111
|
+
: [];
|
|
112
|
+
const lines = [
|
|
113
|
+
"### Coordination / Issue Topology",
|
|
114
|
+
"",
|
|
115
|
+
"First decide whether this issue should publish code itself or mainly coordinate other issues.",
|
|
116
|
+
"If this issue is a parent tracker, umbrella, migration program, or convergence container and the concrete implementation belongs in child issues, do not create a duplicate umbrella PR.",
|
|
117
|
+
"When child issues already own the concrete code slices, use this issue to coordinate, create or refine follow-up issues, or verify convergence. Only ship code here if this issue still has unique implementation scope that is not already owned elsewhere.",
|
|
118
|
+
"Prefer one PR per concrete implementation issue over a broad parent branch that restates overlapping child work.",
|
|
119
|
+
];
|
|
120
|
+
if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
lines.push("", "Known relations from PatchRelay:");
|
|
124
|
+
if (unresolvedBlockers.length > 0) {
|
|
125
|
+
lines.push("Unresolved blockers:");
|
|
126
|
+
lines.push(...summarizeRelationEntries(unresolvedBlockers));
|
|
127
|
+
}
|
|
128
|
+
if (trackedDependents.length > 0) {
|
|
129
|
+
if (unresolvedBlockers.length > 0) {
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
lines.push("Tracked dependent issues:");
|
|
133
|
+
lines.push(...summarizeRelationEntries(trackedDependents));
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
function buildScopeDiscipline(issue, context) {
|
|
69
138
|
const description = issue.description?.trim();
|
|
70
139
|
const scope = extractIssueSection(description, "Scope");
|
|
71
140
|
const acceptance = extractIssueSection(description, "Acceptance criteria")
|
|
@@ -84,6 +153,8 @@ function buildScopeDiscipline(issue) {
|
|
|
84
153
|
...(scope ? ["### In Scope", "", scope, ""] : []),
|
|
85
154
|
...(acceptance ? ["### Acceptance / Done", "", acceptance, ""] : []),
|
|
86
155
|
...(relevantCode ? ["### Relevant Code", "", relevantCode, ""] : []),
|
|
156
|
+
...buildCoordinationGuidance(context),
|
|
157
|
+
"",
|
|
87
158
|
"### Likely Review Invariants",
|
|
88
159
|
"",
|
|
89
160
|
"- Check the surfaces explicitly named in the task before stopping.",
|
|
@@ -91,6 +162,41 @@ function buildScopeDiscipline(issue) {
|
|
|
91
162
|
"- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.",
|
|
92
163
|
].join("\n");
|
|
93
164
|
}
|
|
165
|
+
function buildOrchestrationScopeDiscipline(context) {
|
|
166
|
+
const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
|
|
167
|
+
? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
168
|
+
: [];
|
|
169
|
+
const trackedDependents = Array.isArray(context?.trackedDependents)
|
|
170
|
+
? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
171
|
+
: [];
|
|
172
|
+
return [
|
|
173
|
+
"## Scope Discipline",
|
|
174
|
+
"",
|
|
175
|
+
"This issue is orchestration work.",
|
|
176
|
+
"Treat it as the owner of convergence across related issues rather than as a normal code-owning implementation branch.",
|
|
177
|
+
"Inspect why this wake happened before acting.",
|
|
178
|
+
"Do not create an overlapping umbrella PR unless this parent clearly owns unique direct cleanup work that child issues do not already cover.",
|
|
179
|
+
"If child work is still in motion, babysit the plan, record useful observations, and return to waiting.",
|
|
180
|
+
"If child work looks delivered, audit whether the original parent goal is actually satisfied.",
|
|
181
|
+
"Create blocking follow-up work only when it is necessary to satisfy the original parent goal.",
|
|
182
|
+
"Prefer non-blocking follow-up issues over keeping the umbrella open for optional polish or adjacent expansion.",
|
|
183
|
+
"",
|
|
184
|
+
"### Child Issue Summaries",
|
|
185
|
+
"",
|
|
186
|
+
...(trackedDependents.length > 0
|
|
187
|
+
? summarizeRelationEntries(trackedDependents, { emptyText: "No child issues are currently tracked." })
|
|
188
|
+
: ["No child issues are currently tracked."]),
|
|
189
|
+
"",
|
|
190
|
+
...(unresolvedBlockers.length > 0
|
|
191
|
+
? ["### Unresolved Blockers", "", ...summarizeRelationEntries(unresolvedBlockers), ""]
|
|
192
|
+
: []),
|
|
193
|
+
"### Convergence Rule",
|
|
194
|
+
"",
|
|
195
|
+
"- Close the umbrella when the original parent goal is satisfied.",
|
|
196
|
+
"- If you discover one missing required slice, you may create a justified blocking follow-up.",
|
|
197
|
+
"- Do not invent optional expansion without explicit human approval.",
|
|
198
|
+
].join("\n");
|
|
199
|
+
}
|
|
94
200
|
function buildHumanContext(context) {
|
|
95
201
|
const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
|
|
96
202
|
const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
|
|
@@ -286,18 +392,30 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
|
|
|
286
392
|
"",
|
|
287
393
|
wakeReason === "direct_reply"
|
|
288
394
|
? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
|
|
289
|
-
: wakeReason === "
|
|
290
|
-
? "Why this turn exists:
|
|
291
|
-
: wakeReason === "
|
|
292
|
-
? "Why this turn exists:
|
|
293
|
-
: wakeReason === "
|
|
294
|
-
? "Why this turn exists: A
|
|
295
|
-
:
|
|
395
|
+
: wakeReason === "initial_delegate"
|
|
396
|
+
? "Why this turn exists: This orchestration issue was just delegated and needs an initial plan."
|
|
397
|
+
: wakeReason === "child_delivered"
|
|
398
|
+
? "Why this turn exists: A child issue was delivered and the umbrella needs to review the outcome."
|
|
399
|
+
: wakeReason === "child_changed"
|
|
400
|
+
? "Why this turn exists: A child issue changed state and the umbrella may need to adjust."
|
|
401
|
+
: wakeReason === "child_regressed"
|
|
402
|
+
? "Why this turn exists: A previously progressing child issue regressed and the umbrella needs to reassess."
|
|
403
|
+
: wakeReason === "human_instruction"
|
|
404
|
+
? "Why this turn exists: A human added new guidance for this orchestration issue."
|
|
405
|
+
: wakeReason === "completion_check_continue"
|
|
406
|
+
? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
|
|
407
|
+
: wakeReason === "branch_upkeep"
|
|
408
|
+
? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
|
|
409
|
+
: wakeReason === "followup_comment"
|
|
410
|
+
? "Why this turn exists: A human follow-up comment arrived after the previous turn."
|
|
411
|
+
: `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
|
|
296
412
|
wakeReason === "direct_reply"
|
|
297
413
|
? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
|
|
298
|
-
: wakeReason === "
|
|
299
|
-
? "Required action now:
|
|
300
|
-
:
|
|
414
|
+
: wakeReason === "initial_delegate"
|
|
415
|
+
? "Required action now: Inspect the umbrella goal, review the child set, and record the next orchestration step."
|
|
416
|
+
: wakeReason === "completion_check_continue"
|
|
417
|
+
? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
|
|
418
|
+
: "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
|
|
301
419
|
"",
|
|
302
420
|
];
|
|
303
421
|
if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
|
|
@@ -346,7 +464,26 @@ function buildWorkflowGuidance(repoPath, runType) {
|
|
|
346
464
|
}
|
|
347
465
|
return "";
|
|
348
466
|
}
|
|
349
|
-
function
|
|
467
|
+
function buildOrchestrationWorkflowGuidance() {
|
|
468
|
+
return [
|
|
469
|
+
"## Workflow Guidance",
|
|
470
|
+
"",
|
|
471
|
+
"Use the wake reason and current child issue summaries to decide what kind of orchestration work is needed now.",
|
|
472
|
+
"Typical orchestration phases are: initial setup, waiting on child progress, reviewing delivered child work, final audit, creating a justified follow-up, or closing the umbrella.",
|
|
473
|
+
"Keep outputs concise and observable in Linear.",
|
|
474
|
+
].join("\n");
|
|
475
|
+
}
|
|
476
|
+
function buildPublicationContract(runType, issueClass) {
|
|
477
|
+
if (issueClass === "orchestration") {
|
|
478
|
+
return [
|
|
479
|
+
"## Publication Requirements",
|
|
480
|
+
"",
|
|
481
|
+
"Before finishing, publish the orchestration outcome rather than leaving it implicit.",
|
|
482
|
+
"By default, orchestration work should finish without opening an overlapping umbrella PR.",
|
|
483
|
+
"Valid orchestration outcomes include: recording an observation, updating the rollout plan, creating follow-up issues, opening a small cleanup PR that the parent clearly owns, or closing the umbrella.",
|
|
484
|
+
"If you create new blocking follow-up work, justify it against the original parent goal rather than optional polish.",
|
|
485
|
+
].join("\n");
|
|
486
|
+
}
|
|
350
487
|
if (runType === "implementation") {
|
|
351
488
|
return [
|
|
352
489
|
"## Publication Requirements",
|
|
@@ -384,6 +521,7 @@ function buildPublicationContract(runType) {
|
|
|
384
521
|
].join("\n");
|
|
385
522
|
}
|
|
386
523
|
function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
524
|
+
const issueClass = issue.issueClass;
|
|
387
525
|
const sections = [
|
|
388
526
|
{ id: "header", content: buildPromptHeader(issue) },
|
|
389
527
|
];
|
|
@@ -391,7 +529,10 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
|
391
529
|
if (followUp && reactiveContext) {
|
|
392
530
|
sections.push({ id: "follow-up-turn", content: reactiveContext });
|
|
393
531
|
}
|
|
394
|
-
sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, {
|
|
532
|
+
sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, {
|
|
533
|
+
id: "scope-discipline",
|
|
534
|
+
content: issueClass === "orchestration" ? buildOrchestrationScopeDiscipline(context) : buildScopeDiscipline(issue, context),
|
|
535
|
+
});
|
|
395
536
|
const humanContext = buildHumanContext(context);
|
|
396
537
|
if (humanContext) {
|
|
397
538
|
sections.push({ id: "human-context", content: humanContext });
|
|
@@ -399,11 +540,13 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
|
399
540
|
if (!followUp && reactiveContext) {
|
|
400
541
|
sections.push({ id: "reactive-context", content: reactiveContext });
|
|
401
542
|
}
|
|
402
|
-
const workflow =
|
|
543
|
+
const workflow = issueClass === "orchestration"
|
|
544
|
+
? buildOrchestrationWorkflowGuidance()
|
|
545
|
+
: buildWorkflowGuidance(repoPath, runType);
|
|
403
546
|
if (workflow) {
|
|
404
547
|
sections.push({ id: "workflow-guidance", content: workflow });
|
|
405
548
|
}
|
|
406
|
-
sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
|
|
549
|
+
sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass) });
|
|
407
550
|
return sections;
|
|
408
551
|
}
|
|
409
552
|
function filterAllowedReplacements(promptLayer) {
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -16,6 +16,7 @@ import { RunReconciler } from "./run-reconciler.js";
|
|
|
16
16
|
import { RunRecoveryService } from "./run-recovery-service.js";
|
|
17
17
|
import { RunWakePlanner } from "./run-wake-planner.js";
|
|
18
18
|
import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
|
|
19
|
+
import { classifyIssue } from "./issue-class.js";
|
|
19
20
|
import { loadConfig } from "./config.js";
|
|
20
21
|
function lowerCaseFirst(value) {
|
|
21
22
|
return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
|
|
@@ -30,6 +31,9 @@ function shouldDelayZombieRecoveryLaunch(issue, issueSession, runType) {
|
|
|
30
31
|
return 0;
|
|
31
32
|
return getRemainingZombieRecoveryDelayMs(issue.lastZombieRecoveryAt, issue.zombieRecoveryAttempts);
|
|
32
33
|
}
|
|
34
|
+
function isResolvedDependencyState(stateType) {
|
|
35
|
+
return stateType === "completed" || stateType?.trim().toLowerCase() === "done";
|
|
36
|
+
}
|
|
33
37
|
export class RunOrchestrator {
|
|
34
38
|
config;
|
|
35
39
|
db;
|
|
@@ -135,6 +139,51 @@ export class RunOrchestrator {
|
|
|
135
139
|
materializeLegacyPendingWake(issue, lease) {
|
|
136
140
|
return this.runWakePlanner.materializeLegacyPendingWake(issue, lease);
|
|
137
141
|
}
|
|
142
|
+
buildRelatedIssueContext(issue) {
|
|
143
|
+
const unresolvedBlockers = this.db.issues
|
|
144
|
+
.listIssueDependencies(issue.projectId, issue.linearIssueId)
|
|
145
|
+
.filter((entry) => !isResolvedDependencyState(entry.blockerCurrentLinearStateType))
|
|
146
|
+
.map((entry) => ({
|
|
147
|
+
linearIssueId: entry.blockerLinearIssueId,
|
|
148
|
+
...(entry.blockerIssueKey ? { issueKey: entry.blockerIssueKey } : {}),
|
|
149
|
+
...(entry.blockerTitle ? { title: entry.blockerTitle } : {}),
|
|
150
|
+
...(entry.blockerCurrentLinearState ? { stateName: entry.blockerCurrentLinearState } : {}),
|
|
151
|
+
...(entry.blockerCurrentLinearStateType ? { stateType: entry.blockerCurrentLinearStateType } : {}),
|
|
152
|
+
}));
|
|
153
|
+
const trackedDependents = this.db.issues
|
|
154
|
+
.listDependents(issue.projectId, issue.linearIssueId)
|
|
155
|
+
.map((entry) => this.db.issues.getIssue(issue.projectId, entry.linearIssueId))
|
|
156
|
+
.filter((entry) => Boolean(entry))
|
|
157
|
+
.map((entry) => ({
|
|
158
|
+
linearIssueId: entry.linearIssueId,
|
|
159
|
+
...(entry.issueKey ? { issueKey: entry.issueKey } : {}),
|
|
160
|
+
...(entry.title ? { title: entry.title } : {}),
|
|
161
|
+
factoryState: entry.factoryState,
|
|
162
|
+
...(entry.currentLinearState ? { currentLinearState: entry.currentLinearState } : {}),
|
|
163
|
+
delegatedToPatchRelay: entry.delegatedToPatchRelay,
|
|
164
|
+
hasOpenPr: entry.prNumber !== undefined && entry.prState !== "closed" && entry.prState !== "merged",
|
|
165
|
+
}));
|
|
166
|
+
if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
...(unresolvedBlockers.length > 0 ? { unresolvedBlockers } : {}),
|
|
171
|
+
...(trackedDependents.length > 0 ? { trackedDependents } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
classifyTrackedIssue(issue) {
|
|
175
|
+
const trackedDependentCount = this.db.issues.listDependents(issue.projectId, issue.linearIssueId).length;
|
|
176
|
+
const classification = classifyIssue({ issue, trackedDependentCount });
|
|
177
|
+
if (issue.issueClass === classification.issueClass && issue.issueClassSource === classification.issueClassSource) {
|
|
178
|
+
return issue;
|
|
179
|
+
}
|
|
180
|
+
return this.db.issues.upsertIssue({
|
|
181
|
+
projectId: issue.projectId,
|
|
182
|
+
linearIssueId: issue.linearIssueId,
|
|
183
|
+
issueClass: classification.issueClass,
|
|
184
|
+
issueClassSource: classification.issueClassSource,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
138
187
|
// ─── Run ────────────────────────────────────────────────────────
|
|
139
188
|
async run(item) {
|
|
140
189
|
await this.refreshCodexRuntimeConfig();
|
|
@@ -144,7 +193,8 @@ export class RunOrchestrator {
|
|
|
144
193
|
if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
|
|
145
194
|
return;
|
|
146
195
|
}
|
|
147
|
-
const
|
|
196
|
+
const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
|
|
197
|
+
const issue = initialIssue ? this.classifyTrackedIssue(initialIssue) : undefined;
|
|
148
198
|
if (!issue || issue.activeRunId !== undefined)
|
|
149
199
|
return;
|
|
150
200
|
const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
|
|
@@ -177,9 +227,15 @@ export class RunOrchestrator {
|
|
|
177
227
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
178
228
|
return;
|
|
179
229
|
}
|
|
180
|
-
const
|
|
230
|
+
const baseContext = isRequestedChangesRunType(runType)
|
|
181
231
|
? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
|
|
182
232
|
: context;
|
|
233
|
+
const coordinationContext = runType === "implementation"
|
|
234
|
+
? this.buildRelatedIssueContext(issue)
|
|
235
|
+
: undefined;
|
|
236
|
+
const effectiveContext = coordinationContext
|
|
237
|
+
? { ...coordinationContext, ...(baseContext ?? {}) }
|
|
238
|
+
: baseContext;
|
|
183
239
|
const sourceHeadSha = typeof effectiveContext?.failureHeadSha === "string"
|
|
184
240
|
? effectiveContext.failureHeadSha
|
|
185
241
|
: typeof effectiveContext?.headSha === "string"
|
|
@@ -42,6 +42,7 @@ export function buildTrackedIssueRecord(params) {
|
|
|
42
42
|
projectId: params.issue.projectId,
|
|
43
43
|
linearIssueId: params.issue.linearIssueId,
|
|
44
44
|
delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
|
|
45
|
+
...(params.issue.issueClass ? { issueClass: params.issue.issueClass } : {}),
|
|
45
46
|
...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
|
|
46
47
|
...(params.issue.title ? { title: params.issue.title } : {}),
|
|
47
48
|
...(params.issue.url ? { issueUrl: params.issue.url } : {}),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { triggerEventAllowed } from "../project-resolution.js";
|
|
2
2
|
import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
|
|
3
|
+
import { classifyIssue } from "../issue-class.js";
|
|
3
4
|
const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
|
|
4
5
|
export class CommentWakeHandler {
|
|
5
6
|
db;
|
|
@@ -24,6 +25,10 @@ export class CommentWakeHandler {
|
|
|
24
25
|
const issue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
25
26
|
if (!issue)
|
|
26
27
|
return;
|
|
28
|
+
const issueClass = classifyIssue({
|
|
29
|
+
issue,
|
|
30
|
+
trackedDependentCount: this.db.issues.listDependents(project.id, normalized.issue.id).length,
|
|
31
|
+
}).issueClass;
|
|
27
32
|
const trimmedBody = normalized.comment.body.trim();
|
|
28
33
|
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
29
34
|
const selfAuthored = isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
|
|
@@ -55,7 +60,7 @@ export class CommentWakeHandler {
|
|
|
55
60
|
if (!issue.activeRunId) {
|
|
56
61
|
if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
|
|
57
62
|
const directReply = params.isDirectReplyToOutstandingQuestion(issue);
|
|
58
|
-
const wakeIntent = directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
|
|
63
|
+
const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
|
|
59
64
|
if (!wakeIntent) {
|
|
60
65
|
this.feed?.publish({
|
|
61
66
|
level: "info",
|