patchrelay 0.47.2 → 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 -361
- 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 +86 -15
- package/dist/run-orchestrator.js +18 -3
- 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,416 +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 the appropriate first 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. Implementation issues usually open draft PRs while work is in progress and mark PatchRelay-owned PRs 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
|
-
Not every delegated issue should produce its own PR. Some delegated issues are coordination-only:
|
|
95
|
-
|
|
96
|
-
- parent trackers that spawn or coordinate child implementation issues
|
|
97
|
-
- audit or convergence issues that should wait for child issues before doing a narrow final pass
|
|
98
|
-
- planning/specification issues that are complete once the right follow-up issues or decisions exist
|
|
99
|
-
|
|
100
|
-
In those cases, PatchRelay should avoid opening an overlapping umbrella PR and should finish through coordination, follow-up issue creation, or a no-PR completion path instead.
|
|
101
|
-
|
|
102
|
-
### Undelegation And Re-delegation
|
|
103
|
-
|
|
104
|
-
Undelegation pauses PatchRelay authority. It does not erase PR truth.
|
|
105
|
-
|
|
106
|
-
- If there is no PR yet, the issue keeps its literal local-work state such as `delegated` or `implementing`, but PatchRelay becomes paused.
|
|
107
|
-
- If a PR already exists, the issue keeps its PR-backed state and PatchRelay becomes observer-only.
|
|
108
|
-
- Worktrees, branches, and PRs remain in place.
|
|
109
|
-
- PatchRelay still reflects GitHub review, CI, queue, merge, and close events while undelegated.
|
|
110
|
-
- PatchRelay does not enqueue implementation, review-fix, CI-repair, or queue-repair work again until the issue is delegated back.
|
|
111
|
-
- 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.
|
|
112
|
-
|
|
113
|
-
Downstream services stay PR-centric:
|
|
114
|
-
|
|
115
|
-
- `review-quill` may still review a qualifying PR
|
|
116
|
-
- `merge-steward` may still queue or merge a qualifying PR
|
|
117
|
-
|
|
118
|
-
When the issue is delegated back to PatchRelay, it should resume from current truth:
|
|
119
|
-
|
|
120
|
-
- no PR: queue implementation
|
|
121
|
-
- PR with requested changes: queue review fix or branch upkeep
|
|
122
|
-
- PR with failing CI: queue CI repair
|
|
123
|
-
- PR with queue eviction/conflict: queue queue repair
|
|
124
|
-
- healthy open PR: keep waiting on review
|
|
125
|
-
- approved PR: keep waiting downstream
|
|
126
|
-
|
|
127
|
-
## Ownership Model
|
|
128
|
-
|
|
129
|
-
PatchRelay keeps ownership simple:
|
|
130
|
-
|
|
131
|
-
- workflow truth: the current factory state plus GitHub PR/review/CI facts
|
|
132
|
-
- runtime authority: whether PatchRelay may actively write or repair code right now
|
|
133
|
-
|
|
134
|
-
PatchRelay persists one explicit authority bit:
|
|
135
|
-
|
|
136
|
-
- `delegatedToPatchRelay`: whether PatchRelay may actively implement or repair code for the issue right now
|
|
137
|
-
|
|
138
|
-
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.
|
|
139
|
-
|
|
140
|
-
That authority does not change just because:
|
|
141
|
-
|
|
142
|
-
- the issue is undelegated
|
|
143
|
-
- the PR becomes ready for review
|
|
144
|
-
- the PR is approved
|
|
145
|
-
- the PR enters or leaves the merge queue
|
|
146
|
-
|
|
147
|
-
## Factory State Machine
|
|
148
|
-
|
|
149
|
-
Each issue progresses through a factory state machine:
|
|
150
|
-
|
|
151
|
-
```text
|
|
152
|
-
delegated → preparing → implementing → pr_open → awaiting_review
|
|
153
|
-
→ changes_requested (review fix run) → back to implementing
|
|
154
|
-
→ repairing_ci (CI repair run) → back to pr_open
|
|
155
|
-
→ awaiting_queue → done (merged)
|
|
156
|
-
→ repairing_queue (queue repair run) → back to pr_open
|
|
157
|
-
→ escalated or failed (when retry budgets are exhausted)
|
|
31
|
+
```
|
|
32
|
+
/plugin marketplace add krasnoperov/patchrelay-agents
|
|
33
|
+
/plugin install ship-pr@patchrelay
|
|
158
34
|
```
|
|
159
35
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
- `implementation` — initial coding work
|
|
163
|
-
- `review_fix` — address reviewer feedback
|
|
164
|
-
- `ci_repair` — fix failing CI checks
|
|
165
|
-
- `queue_repair` — fix merge queue failures
|
|
166
|
-
|
|
167
|
-
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.
|
|
168
|
-
|
|
169
|
-
The long-term runtime model is a small durable `IssueSession`:
|
|
170
|
-
|
|
171
|
-
- `idle`
|
|
172
|
-
- `running`
|
|
173
|
-
- `waiting_input`
|
|
174
|
-
- `done`
|
|
175
|
-
- `failed`
|
|
176
|
-
|
|
177
|
-
Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
|
|
178
|
-
|
|
179
|
-
`awaiting_input` is reserved for real human-needed situations:
|
|
180
|
-
|
|
181
|
-
- a completion check asked a question
|
|
182
|
-
- an operator explicitly stopped the run and wants a next decision
|
|
183
|
-
- a reply is required before PatchRelay can continue
|
|
184
|
-
|
|
185
|
-
Undelegated local work should stay in its literal workflow state and show a paused waiting reason instead.
|
|
186
|
-
|
|
187
|
-
## Restart And Reconciliation
|
|
188
|
-
|
|
189
|
-
PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
|
|
190
|
-
|
|
191
|
-
After a restart, the service can answer:
|
|
192
|
-
|
|
193
|
-
- which issue owns each active worktree
|
|
194
|
-
- which run was active or queued
|
|
195
|
-
- which Codex thread and turn belong to that work
|
|
196
|
-
- whether the issue is still eligible to continue
|
|
197
|
-
- whether the run should resume, hand off, or fail back to a human state
|
|
198
|
-
|
|
199
|
-
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.
|
|
200
|
-
|
|
201
|
-
## Workflow Files
|
|
202
|
-
|
|
203
|
-
PatchRelay uses repo-local workflow files as prompts for Codex runs:
|
|
204
|
-
|
|
205
|
-
- `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
|
|
206
|
-
- `REVIEW_WORKFLOW.md` — used for review fix runs
|
|
207
|
-
|
|
208
|
-
These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
|
|
209
|
-
|
|
210
|
-
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).
|
|
211
|
-
|
|
212
|
-
## Knowledge And Validation Surfaces
|
|
213
|
-
|
|
214
|
-
PatchRelay works best when repository guidance follows progressive disclosure:
|
|
215
|
-
|
|
216
|
-
- keep the root entrypoints short and navigational
|
|
217
|
-
- treat deeper `docs/` content as the durable system of record
|
|
218
|
-
- capture architecture, workflow, and product decisions in versioned files instead of chat history or operator memory
|
|
219
|
-
|
|
220
|
-
PatchRelay should also help agents validate their own work inside the issue loop:
|
|
221
|
-
|
|
222
|
-
- package the smallest useful context for the current run instead of replaying ever-growing transcript history
|
|
223
|
-
- preserve high-signal failure evidence such as review feedback, failing checks, and queue incidents
|
|
224
|
-
- make repo-local validation surfaces legible per worktree so the next run can see what passed, what failed, and what needs repair
|
|
225
|
-
|
|
226
|
-
Keeping those knowledge and validation surfaces clean is part of the harness, not optional documentation polish.
|
|
227
|
-
|
|
228
|
-
## Access Control
|
|
229
|
-
|
|
230
|
-
PatchRelay reacts only for issues that route to a configured project.
|
|
231
|
-
|
|
232
|
-
- use `linear_team_ids`, `issue_key_prefixes`, and optional labels to keep unrelated or public boards out of scope
|
|
233
|
-
- in the normal setup, anyone with access to the routed Linear project can delegate work to the PatchRelay app
|
|
234
|
-
- 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.
|
|
235
37
|
|
|
236
|
-
|
|
38
|
+
## Quick start (PatchRelay harness)
|
|
237
39
|
|
|
238
|
-
|
|
40
|
+
Prerequisites:
|
|
239
41
|
|
|
240
|
-
|
|
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
|
|
241
46
|
|
|
242
47
|
```bash
|
|
243
48
|
npm install -g patchrelay
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
### 2. Bootstrap config
|
|
247
|
-
|
|
248
|
-
```bash
|
|
249
49
|
patchrelay init https://patchrelay.example.com
|
|
250
50
|
```
|
|
251
51
|
|
|
252
|
-
`
|
|
253
|
-
|
|
254
|
-
It creates the local config, env file, and system service units:
|
|
255
|
-
|
|
256
|
-
- `~/.config/patchrelay/runtime.env`
|
|
257
|
-
- `~/.config/patchrelay/service.env`
|
|
258
|
-
- `~/.config/patchrelay/patchrelay.json`
|
|
259
|
-
- `/etc/systemd/system/patchrelay.service`
|
|
260
|
-
|
|
261
|
-
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.
|
|
262
|
-
|
|
263
|
-
### 3. Configure access
|
|
264
|
-
|
|
265
|
-
Edit `~/.config/patchrelay/service.env` and fill in only the Linear OAuth client values. Keep the generated webhook secret and token-encryption key:
|
|
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:
|
|
266
53
|
|
|
267
54
|
```bash
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
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.
|
|
275
|
-
|
|
276
|
-
### 4. Connect PatchRelay to Linear
|
|
277
|
-
|
|
278
|
-
Connect PatchRelay to one Linear workspace:
|
|
279
|
-
|
|
280
|
-
```bash
|
|
281
|
-
patchrelay linear connect
|
|
282
|
-
patchrelay linear sync
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
This authorizes the workspace once, then caches its teams and projects locally. Workspace auth is separate from repo linking.
|
|
286
|
-
|
|
287
|
-
### 5. Link a GitHub repo
|
|
288
|
-
|
|
289
|
-
Link repos by GitHub identity, not by local path:
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
patchrelay repo link krasnoperov/usertold --workspace usertold --team USE
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
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.
|
|
296
|
-
|
|
297
|
-
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.
|
|
298
|
-
|
|
299
|
-
`patchrelay repo link` is idempotent:
|
|
300
|
-
|
|
301
|
-
- it creates or updates the linked repo entry
|
|
302
|
-
- it refreshes the selected Linear workspace catalog before resolving teams/projects
|
|
303
|
-
- it reloads the service when it can
|
|
304
|
-
- if workflow files or secrets are still missing, it tells you exactly what to fix and can be rerun safely
|
|
305
|
-
|
|
306
|
-
### 6. Add workflow docs to the repo
|
|
307
|
-
|
|
308
|
-
PatchRelay looks for:
|
|
309
|
-
|
|
310
|
-
```text
|
|
311
|
-
IMPLEMENTATION_WORKFLOW.md
|
|
312
|
-
REVIEW_WORKFLOW.md
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
These files define how the agent should work in that repo.
|
|
316
|
-
|
|
317
|
-
### 7. Validate
|
|
318
|
-
|
|
319
|
-
```bash
|
|
320
|
-
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
|
|
321
60
|
patchrelay service status
|
|
322
61
|
patchrelay dashboard
|
|
323
62
|
```
|
|
324
63
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
```bash
|
|
328
|
-
patchrelay linear list
|
|
329
|
-
patchrelay repo list
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
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.
|
|
333
|
-
|
|
334
|
-
Important:
|
|
64
|
+
Each repo needs two workflow files that act as agent prompts:
|
|
335
65
|
|
|
336
|
-
-
|
|
337
|
-
- `
|
|
338
|
-
- 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
|
|
339
68
|
|
|
340
|
-
|
|
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.
|
|
341
70
|
|
|
342
|
-
|
|
343
|
-
2. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches an implementation run.
|
|
344
|
-
3. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
|
|
345
|
-
4. GitHub webhooks automatically trigger CI repair, review fix, or merge queue repair runs when needed.
|
|
346
|
-
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).
|
|
347
72
|
|
|
348
|
-
|
|
73
|
+
## How it works
|
|
349
74
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
- `patchrelay issue retry APP-123`
|
|
357
|
-
- `patchrelay service restart`
|
|
358
|
-
- `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.
|
|
359
81
|
|
|
360
|
-
|
|
361
|
-
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).
|
|
362
83
|
|
|
363
|
-
|
|
84
|
+
## Downstream services
|
|
364
85
|
|
|
365
|
-
|
|
86
|
+
Two separate services handle review and delivery. Both are independent, GitHub-native, and usable without PatchRelay.
|
|
366
87
|
|
|
367
|
-
|
|
368
|
-
2. `patchrelay issue show APP-123` or `patchrelay issue watch APP-123` to inspect one issue in more detail.
|
|
369
|
-
3. `patchrelay issue open APP-123` to take over inside the exact worktree and continue from the same issue context.
|
|
370
|
-
4. `patchrelay service logs --lines 100` if the problem looks like webhook intake, Codex startup, or service runtime failure.
|
|
88
|
+
### review-quill
|
|
371
89
|
|
|
372
|
-
|
|
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.
|
|
373
91
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
- which run is active or queued
|
|
380
|
-
- which Codex thread owns the current work
|
|
381
|
-
- what the agent said
|
|
382
|
-
- which commands it ran
|
|
383
|
-
- which files it changed
|
|
384
|
-
- whether the run completed, failed, or needs handoff
|
|
385
|
-
|
|
386
|
-
## 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
|
+
```
|
|
387
97
|
|
|
388
|
-
[
|
|
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.
|
|
389
99
|
|
|
390
|
-
|
|
100
|
+
### merge-steward
|
|
391
101
|
|
|
392
|
-
|
|
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.
|
|
393
103
|
|
|
394
104
|
```bash
|
|
395
105
|
merge-steward init https://queue.example.com
|
|
396
|
-
merge-steward attach
|
|
397
|
-
merge-steward doctor --repo
|
|
106
|
+
merge-steward attach owner/repo --base-branch main
|
|
107
|
+
merge-steward doctor --repo repo
|
|
398
108
|
merge-steward service status
|
|
399
|
-
merge-steward queue status --repo app
|
|
400
109
|
```
|
|
401
110
|
|
|
402
|
-
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.
|
|
403
112
|
|
|
404
113
|
## Docs
|
|
405
114
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
- [
|
|
409
|
-
- [
|
|
410
|
-
- [
|
|
411
|
-
- [
|
|
412
|
-
- [
|
|
413
|
-
- [
|
|
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)
|
|
414
123
|
- [Security policy](./SECURITY.md)
|
|
415
124
|
|
|
416
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
|
+
}
|
|
@@ -162,6 +162,41 @@ function buildScopeDiscipline(issue, context) {
|
|
|
162
162
|
"- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.",
|
|
163
163
|
].join("\n");
|
|
164
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
|
+
}
|
|
165
200
|
function buildHumanContext(context) {
|
|
166
201
|
const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
|
|
167
202
|
const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
|
|
@@ -357,18 +392,30 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
|
|
|
357
392
|
"",
|
|
358
393
|
wakeReason === "direct_reply"
|
|
359
394
|
? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
|
|
360
|
-
: wakeReason === "
|
|
361
|
-
? "Why this turn exists:
|
|
362
|
-
: wakeReason === "
|
|
363
|
-
? "Why this turn exists:
|
|
364
|
-
: wakeReason === "
|
|
365
|
-
? "Why this turn exists: A
|
|
366
|
-
:
|
|
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.`,
|
|
367
412
|
wakeReason === "direct_reply"
|
|
368
413
|
? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
|
|
369
|
-
: wakeReason === "
|
|
370
|
-
? "Required action now:
|
|
371
|
-
:
|
|
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.",
|
|
372
419
|
"",
|
|
373
420
|
];
|
|
374
421
|
if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
|
|
@@ -417,14 +464,32 @@ function buildWorkflowGuidance(repoPath, runType) {
|
|
|
417
464
|
}
|
|
418
465
|
return "";
|
|
419
466
|
}
|
|
420
|
-
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
|
+
}
|
|
421
487
|
if (runType === "implementation") {
|
|
422
488
|
return [
|
|
423
489
|
"## Publication Requirements",
|
|
424
490
|
"",
|
|
425
491
|
"Before finishing, publish the result instead of leaving it only in the worktree.",
|
|
426
492
|
"If the task is genuinely complete without a PR, say so clearly in your normal summary instead of inventing one.",
|
|
427
|
-
"If the issue is acting as coordination-only work and the real implementation belongs in child issues, finish without opening an overlapping umbrella PR.",
|
|
428
493
|
"If the worktree already contains relevant changes for this issue, verify them and publish them.",
|
|
429
494
|
"If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
|
|
430
495
|
"Do not stop with only local commits or uncommitted changes.",
|
|
@@ -456,6 +521,7 @@ function buildPublicationContract(runType) {
|
|
|
456
521
|
].join("\n");
|
|
457
522
|
}
|
|
458
523
|
function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
524
|
+
const issueClass = issue.issueClass;
|
|
459
525
|
const sections = [
|
|
460
526
|
{ id: "header", content: buildPromptHeader(issue) },
|
|
461
527
|
];
|
|
@@ -463,7 +529,10 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
|
463
529
|
if (followUp && reactiveContext) {
|
|
464
530
|
sections.push({ id: "follow-up-turn", content: reactiveContext });
|
|
465
531
|
}
|
|
466
|
-
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
|
+
});
|
|
467
536
|
const humanContext = buildHumanContext(context);
|
|
468
537
|
if (humanContext) {
|
|
469
538
|
sections.push({ id: "human-context", content: humanContext });
|
|
@@ -471,11 +540,13 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
|
471
540
|
if (!followUp && reactiveContext) {
|
|
472
541
|
sections.push({ id: "reactive-context", content: reactiveContext });
|
|
473
542
|
}
|
|
474
|
-
const workflow =
|
|
543
|
+
const workflow = issueClass === "orchestration"
|
|
544
|
+
? buildOrchestrationWorkflowGuidance()
|
|
545
|
+
: buildWorkflowGuidance(repoPath, runType);
|
|
475
546
|
if (workflow) {
|
|
476
547
|
sections.push({ id: "workflow-guidance", content: workflow });
|
|
477
548
|
}
|
|
478
|
-
sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
|
|
549
|
+
sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass) });
|
|
479
550
|
return sections;
|
|
480
551
|
}
|
|
481
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;
|
|
@@ -138,7 +139,7 @@ export class RunOrchestrator {
|
|
|
138
139
|
materializeLegacyPendingWake(issue, lease) {
|
|
139
140
|
return this.runWakePlanner.materializeLegacyPendingWake(issue, lease);
|
|
140
141
|
}
|
|
141
|
-
|
|
142
|
+
buildRelatedIssueContext(issue) {
|
|
142
143
|
const unresolvedBlockers = this.db.issues
|
|
143
144
|
.listIssueDependencies(issue.projectId, issue.linearIssueId)
|
|
144
145
|
.filter((entry) => !isResolvedDependencyState(entry.blockerCurrentLinearStateType))
|
|
@@ -170,6 +171,19 @@ export class RunOrchestrator {
|
|
|
170
171
|
...(trackedDependents.length > 0 ? { trackedDependents } : {}),
|
|
171
172
|
};
|
|
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
|
+
}
|
|
173
187
|
// ─── Run ────────────────────────────────────────────────────────
|
|
174
188
|
async run(item) {
|
|
175
189
|
await this.refreshCodexRuntimeConfig();
|
|
@@ -179,7 +193,8 @@ export class RunOrchestrator {
|
|
|
179
193
|
if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
|
|
180
194
|
return;
|
|
181
195
|
}
|
|
182
|
-
const
|
|
196
|
+
const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
|
|
197
|
+
const issue = initialIssue ? this.classifyTrackedIssue(initialIssue) : undefined;
|
|
183
198
|
if (!issue || issue.activeRunId !== undefined)
|
|
184
199
|
return;
|
|
185
200
|
const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
|
|
@@ -216,7 +231,7 @@ export class RunOrchestrator {
|
|
|
216
231
|
? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
|
|
217
232
|
: context;
|
|
218
233
|
const coordinationContext = runType === "implementation"
|
|
219
|
-
? this.
|
|
234
|
+
? this.buildRelatedIssueContext(issue)
|
|
220
235
|
: undefined;
|
|
221
236
|
const effectiveContext = coordinationContext
|
|
222
237
|
? { ...coordinationContext, ...(baseContext ?? {}) }
|
|
@@ -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",
|