patchrelay 0.37.1 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +47 -9
  2. package/dist/awaiting-input-reason.js +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/cluster-health.js +59 -3
  5. package/dist/cli/help.js +1 -1
  6. package/dist/cli/output.js +2 -0
  7. package/dist/db/issue-session-store.js +0 -14
  8. package/dist/db/issue-store.js +8 -16
  9. package/dist/db/migrations.js +6 -13
  10. package/dist/db.js +1 -3
  11. package/dist/github-linear-session-sync.js +57 -0
  12. package/dist/github-pr-comment-handler.js +74 -0
  13. package/dist/github-webhook-failure-context.js +70 -0
  14. package/dist/github-webhook-handler.js +49 -965
  15. package/dist/github-webhook-issue-resolution.js +46 -0
  16. package/dist/github-webhook-policy.js +105 -0
  17. package/dist/github-webhook-reactive-run.js +302 -0
  18. package/dist/github-webhook-state-projector.js +231 -0
  19. package/dist/github-webhook-terminal-handler.js +111 -0
  20. package/dist/github-webhooks.js +4 -0
  21. package/dist/idle-reconciliation.js +22 -23
  22. package/dist/issue-overview-query.js +11 -57
  23. package/dist/issue-session-projector.js +1 -0
  24. package/dist/issue-session.js +8 -0
  25. package/dist/legacy-issue-overview.js +58 -0
  26. package/dist/linear-session-reporting.js +30 -1
  27. package/dist/linear-session-sync.js +9 -1
  28. package/dist/linear-status-comment-sync.js +34 -1
  29. package/dist/linear-workflow-state-sync.js +2 -2
  30. package/dist/operator-retry-event.js +15 -12
  31. package/dist/paused-issue-state.js +24 -0
  32. package/dist/reactive-pr-state.js +65 -0
  33. package/dist/reactive-run-policy.js +35 -118
  34. package/dist/remote-pr-state.js +11 -0
  35. package/dist/run-launcher.js +0 -1
  36. package/dist/run-orchestrator.js +22 -11
  37. package/dist/run-reconciler.js +10 -0
  38. package/dist/run-recovery-service.js +1 -10
  39. package/dist/service-issue-actions.js +5 -0
  40. package/dist/service-startup-recovery.js +9 -6
  41. package/dist/service.js +0 -1
  42. package/dist/tracked-issue-list-query.js +3 -1
  43. package/dist/tracked-issue-projector.js +3 -0
  44. package/dist/waiting-reason.js +10 -0
  45. package/dist/webhooks/agent-session-handler.js +9 -1
  46. package/dist/webhooks/comment-wake-handler.js +12 -0
  47. package/dist/webhooks/decision-helpers.js +44 -3
  48. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  49. package/dist/webhooks/desired-stage-recorder.js +40 -10
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # PatchRelay
2
2
 
3
- PatchRelay is a self-hosted harness for delegated Linear work and upkeep of PatchRelay-owned pull requests on your own machine.
3
+ PatchRelay is a self-hosted harness for delegated Linear work and upkeep of linked pull requests on your own machine.
4
4
 
5
- It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on PatchRelay-owned PRs. Separate downstream services own review automation and merge execution.
5
+ It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on linked delegated PRs. Separate downstream services own review automation and merge execution.
6
6
 
7
7
  PatchRelay is the system around the model:
8
8
 
@@ -38,8 +38,9 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
38
38
  - creates and reuses one durable worktree and branch per issue lifecycle
39
39
  - starts Codex threads for implementation runs
40
40
  - triggers reactive runs for CI failures, review feedback, and Merge Steward evictions
41
- - opens and updates PatchRelay-owned PRs
41
+ - opens and updates PRs for delegated implementation work
42
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
43
44
  - persists enough state to correlate the Linear issue, local workspace, run, and Codex thread
44
45
  - reports progress back to Linear and forwards follow-up agent input into active runs
45
46
  - exposes CLI and optional read-only inspection surfaces so operators can understand what happened
@@ -87,19 +88,48 @@ You will also need:
87
88
  5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures and review fix on changes requested.
88
89
  6. PatchRelay opens draft PRs while implementation is in progress and marks its own PR ready when implementation is complete.
89
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.
90
- 8. If requested changes, red CI, or a merge-steward incident lands on a PatchRelay-owned PR, PatchRelay resumes work on that same PR branch.
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.
91
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.
92
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
+
93
119
  ## Ownership Model
94
120
 
95
- PatchRelay tracks two different kinds of ownership:
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:
96
127
 
97
- - issue ownership: who may start new delegated implementation work from Linear
98
- - PR ownership: who is responsible for keeping an existing PR healthy until it merges or closes
128
+ - `delegatedToPatchRelay`: whether PatchRelay may actively implement or repair code for the issue right now
99
129
 
100
- For PatchRelay, PR ownership is determined by one concrete GitHub fact: a PR is PatchRelay-owned when its author is the PatchRelay GitHub app or service account.
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.
101
131
 
102
- That ownership does not change just because:
132
+ That authority does not change just because:
103
133
 
104
134
  - the issue is undelegated
105
135
  - the PR becomes ready for review
@@ -138,6 +168,14 @@ The long-term runtime model is a small durable `IssueSession`:
138
168
 
139
169
  Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
140
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
+
141
179
  ## Restart And Reconciliation
142
180
 
143
181
  PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
@@ -0,0 +1,9 @@
1
+ export function resolveAwaitingInputReason(params) {
2
+ if (params.issue.factoryState !== "awaiting_input") {
3
+ return undefined;
4
+ }
5
+ if (params.latestRun?.completionCheckOutcome === "needs_input") {
6
+ return "completion_check_question";
7
+ }
8
+ return "paused_local_work";
9
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.37.1",
4
- "commit": "e2c1f3d45497",
5
- "builtAt": "2026-04-10T17:31:17.533Z"
3
+ "version": "0.38.1",
4
+ "commit": "f8444979b2ac",
5
+ "builtAt": "2026-04-10T18:39:19.100Z"
6
6
  }
@@ -302,6 +302,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
302
302
  }
303
303
  const ciEntry = buildCiEntry({
304
304
  issue,
305
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
305
306
  gateCheckStatus,
306
307
  reviewDecision,
307
308
  reviewRequested,
@@ -310,7 +311,31 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
310
311
  mergeConflictDetected,
311
312
  reviewQuillAttempt,
312
313
  });
313
- if (gateCheckStatus === "failure" && issue.factoryState !== "repairing_ci" && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
314
+ if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
315
+ return {
316
+ ciEntry,
317
+ finding: {
318
+ status: "fail",
319
+ scope: "github:reconcile",
320
+ message: "PR is already merged but the issue has not advanced to done",
321
+ },
322
+ };
323
+ }
324
+ if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
325
+ return {
326
+ ciEntry,
327
+ finding: {
328
+ status: "fail",
329
+ scope: "github:reconcile",
330
+ message: "PR is closed but the issue is still waiting on PR state",
331
+ },
332
+ };
333
+ }
334
+ if (issue.delegatedToPatchRelay
335
+ && gateCheckStatus === "failure"
336
+ && issue.factoryState !== "repairing_ci"
337
+ && issue.activeRunId === undefined
338
+ && ageMs >= RECONCILIATION_GRACE_MS) {
314
339
  return {
315
340
  ciEntry,
316
341
  finding: {
@@ -333,6 +358,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
333
358
  if (gateCheckStatus === "success"
334
359
  && reviewDecision === "CHANGES_REQUESTED"
335
360
  && mergeConflictDetected
361
+ && issue.delegatedToPatchRelay
336
362
  && issue.factoryState !== "changes_requested"
337
363
  && issue.activeRunId === undefined
338
364
  && ageMs >= RECONCILIATION_GRACE_MS) {
@@ -349,6 +375,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
349
375
  && reviewDecision === "CHANGES_REQUESTED"
350
376
  && latestBlockingReviewHeadSha === pr.headRefOid
351
377
  && !reviewQuillAttempt
378
+ && issue.delegatedToPatchRelay
352
379
  && issue.factoryState !== "changes_requested"
353
380
  && ageMs >= RECONCILIATION_GRACE_MS) {
354
381
  return {
@@ -370,7 +397,11 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
370
397
  },
371
398
  };
372
399
  }
373
- if (issue.factoryState === "awaiting_queue" && mergeConflictDetected && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
400
+ if (issue.delegatedToPatchRelay
401
+ && issue.factoryState === "awaiting_queue"
402
+ && mergeConflictDetected
403
+ && issue.activeRunId === undefined
404
+ && ageMs >= RECONCILIATION_GRACE_MS) {
374
405
  return {
375
406
  ciEntry,
376
407
  finding: {
@@ -393,8 +424,9 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
393
424
  return { ciEntry };
394
425
  }
395
426
  function buildCiEntry(params) {
396
- const { issue, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
427
+ const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
397
428
  const owner = deriveCiOwner({
429
+ delegatedToPatchRelay,
398
430
  gateCheckStatus,
399
431
  factoryState: issue.factoryState,
400
432
  reviewDecision,
@@ -414,6 +446,7 @@ function buildCiEntry(params) {
414
446
  factoryState: issue.factoryState,
415
447
  ...(reviewDecision ? { reviewDecision } : {}),
416
448
  message: describeCiOwnership({
449
+ delegatedToPatchRelay,
417
450
  gateCheckStatus,
418
451
  owner,
419
452
  reviewDecision,
@@ -430,20 +463,29 @@ function deriveCiOwner(params) {
430
463
  && params.latestBlockingReviewHeadSha
431
464
  && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
432
465
  if (params.gateCheckStatus === "failure") {
466
+ if (!params.delegatedToPatchRelay)
467
+ return "paused";
433
468
  return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
434
469
  }
435
470
  if (params.gateCheckStatus === "pending") {
436
471
  return "external";
437
472
  }
438
473
  if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
474
+ if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
475
+ return "paused";
476
+ }
439
477
  return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
440
478
  ? "unknown"
441
479
  : "downstream";
442
480
  }
443
481
  if (params.reviewDecision === "CHANGES_REQUESTED") {
444
482
  if (params.mergeConflictDetected) {
483
+ if (!params.delegatedToPatchRelay)
484
+ return "paused";
445
485
  return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
446
486
  }
487
+ if (!params.delegatedToPatchRelay)
488
+ return "paused";
447
489
  if (params.factoryState === "changes_requested")
448
490
  return "patchrelay";
449
491
  if (params.reviewQuillAttempt)
@@ -502,6 +544,20 @@ function describeCiOwnership(params) {
502
544
  ? "Waiting on external CI checks to settle"
503
545
  : "Waiting on external GitHub automation";
504
546
  }
547
+ if (params.owner === "paused") {
548
+ if (params.gateCheckStatus === "failure") {
549
+ return "PatchRelay is paused; delegate the issue again to repair failing CI";
550
+ }
551
+ if (params.reviewDecision === "CHANGES_REQUESTED") {
552
+ return params.mergeConflictDetected
553
+ ? "PatchRelay is paused; delegate the issue again to repair the blocked PR branch"
554
+ : "PatchRelay is paused; delegate the issue again to address requested changes";
555
+ }
556
+ if (params.mergeConflictDetected) {
557
+ return "PatchRelay is paused; delegate the issue again to repair this merge conflict";
558
+ }
559
+ return "PatchRelay is paused; no automatic repair will start until the issue is delegated again";
560
+ }
505
561
  if (params.reviewDecision === "CHANGES_REQUESTED") {
506
562
  if (params.mergeConflictDetected) {
507
563
  return headAdvancedPastBlockingReview
package/dist/cli/help.js CHANGED
@@ -144,7 +144,7 @@ export function issueHelpText() {
144
144
  "Commands:",
145
145
  " show <issueKey> Show the latest known issue state",
146
146
  " list List tracked issues",
147
- " watch <issueKey> Follow PatchRelay-owned activity until it settles",
147
+ " watch <issueKey> Follow issue activity until it settles",
148
148
  " path <issueKey> Print the issue worktree path",
149
149
  " open <issueKey> Open Codex in the issue worktree",
150
150
  " sessions <issueKey> Show recorded Codex app-server sessions",
@@ -56,6 +56,8 @@ function formatCiOwnerLabel(owner) {
56
56
  return "merge-queue";
57
57
  case "external":
58
58
  return "ci/github";
59
+ case "paused":
60
+ return "paused";
59
61
  default:
60
62
  return "missing";
61
63
  }
@@ -263,20 +263,6 @@ export class IssueSessionStore {
263
263
  WHERE project_id = ? AND linear_issue_id = ?
264
264
  `).run(lastWakeReason ?? null, isoNow(), projectId, linearIssueId);
265
265
  }
266
- setBranchOwnerWithLease(lease, owner) {
267
- return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
268
- this.issues.setBranchOwner(lease.projectId, lease.linearIssueId, owner);
269
- return true;
270
- }) ?? false;
271
- }
272
- setBranchOwnerRespectingActiveLease(projectId, linearIssueId, owner) {
273
- const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
274
- if (!lease) {
275
- this.issues.setBranchOwner(projectId, linearIssueId, owner);
276
- return true;
277
- }
278
- return this.setBranchOwnerWithLease(lease, owner);
279
- }
280
266
  releaseIssueSessionLeaseRespectingActiveLease(projectId, linearIssueId) {
281
267
  const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
282
268
  this.releaseIssueSessionLease(projectId, linearIssueId, lease?.leaseId);
@@ -16,6 +16,10 @@ export class IssueStore {
16
16
  projectId: params.projectId,
17
17
  linearIssueId: params.linearIssueId,
18
18
  };
19
+ if (params.delegatedToPatchRelay !== undefined) {
20
+ sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
21
+ values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
22
+ }
19
23
  if (params.issueKey !== undefined) {
20
24
  sets.push("issue_key = COALESCE(@issueKey, issue_key)");
21
25
  values.issueKey = params.issueKey;
@@ -205,7 +209,7 @@ export class IssueStore {
205
209
  else {
206
210
  this.connection.prepare(`
207
211
  INSERT INTO issues (
208
- project_id, linear_issue_id, issue_key, title, description, url,
212
+ project_id, linear_issue_id, delegated_to_patchrelay, issue_key, title, description, url,
209
213
  priority, estimate,
210
214
  current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
211
215
  branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
@@ -218,7 +222,7 @@ export class IssueStore {
218
222
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
219
223
  updated_at
220
224
  ) VALUES (
221
- @projectId, @linearIssueId, @issueKey, @title, @description, @url,
225
+ @projectId, @linearIssueId, @delegatedToPatchRelay, @issueKey, @title, @description, @url,
222
226
  @priority, @estimate,
223
227
  @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
224
228
  @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
@@ -234,6 +238,7 @@ export class IssueStore {
234
238
  `).run({
235
239
  projectId: params.projectId,
236
240
  linearIssueId: params.linearIssueId,
241
+ delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
237
242
  issueKey: params.issueKey ?? null,
238
243
  title: params.title ?? null,
239
244
  description: params.description ?? null,
@@ -357,14 +362,6 @@ export class IssueStore {
357
362
  .all();
358
363
  return rows.map(mapIssueRow);
359
364
  }
360
- setBranchOwner(projectId, linearIssueId, owner) {
361
- const now = isoNow();
362
- this.connection.prepare(`
363
- UPDATE issues
364
- SET branch_owner = ?, branch_ownership_changed_at = ?, updated_at = ?
365
- WHERE project_id = ? AND linear_issue_id = ?
366
- `).run(owner, now, now, projectId, linearIssueId);
367
- }
368
365
  replaceIssueDependencies(params) {
369
366
  const now = isoNow();
370
367
  this.connection
@@ -466,6 +463,7 @@ export function mapIssueRow(row) {
466
463
  id: Number(row.id),
467
464
  projectId: String(row.project_id),
468
465
  linearIssueId: String(row.linear_issue_id),
466
+ delegatedToPatchRelay: Number(row.delegated_to_patchrelay ?? 1) !== 0,
469
467
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
470
468
  ...(row.title !== null ? { title: String(row.title) } : {}),
471
469
  ...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
@@ -480,12 +478,6 @@ export function mapIssueRow(row) {
480
478
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
481
479
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
482
480
  ...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
483
- ...(row.branch_owner !== null && row.branch_owner !== undefined && String(row.branch_owner) === "patchrelay"
484
- ? { branchOwner: "patchrelay" }
485
- : { branchOwner: "patchrelay" }),
486
- ...(row.branch_ownership_changed_at !== null && row.branch_ownership_changed_at !== undefined
487
- ? { branchOwnershipChangedAt: String(row.branch_ownership_changed_at) }
488
- : {}),
489
481
  ...(row.worktree_path !== null ? { worktreePath: String(row.worktree_path) } : {}),
490
482
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
491
483
  ...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS issues (
3
3
  id INTEGER PRIMARY KEY AUTOINCREMENT,
4
4
  project_id TEXT NOT NULL,
5
5
  linear_issue_id TEXT NOT NULL,
6
+ delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
6
7
  issue_key TEXT,
7
8
  title TEXT,
8
9
  url TEXT,
@@ -12,8 +13,6 @@ CREATE TABLE IF NOT EXISTS issues (
12
13
  pending_run_type TEXT,
13
14
  pending_run_context_json TEXT,
14
15
  branch_name TEXT,
15
- branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
16
- branch_ownership_changed_at TEXT,
17
16
  worktree_path TEXT,
18
17
  thread_id TEXT,
19
18
  active_run_id INTEGER,
@@ -239,12 +238,9 @@ export function runPatchRelayMigrations(connection) {
239
238
  connection.exec(schema);
240
239
  // Clean up stale dedupe-only webhook records (no payload, never processable)
241
240
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
241
+ addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
242
242
  // Add pending_merge_prep column for merge queue stewardship
243
243
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
244
- // Explicit PR branch ownership hand-off between PatchRelay and MergeSteward
245
- addColumnIfMissing(connection, "issues", "branch_owner", "TEXT NOT NULL DEFAULT 'patchrelay'");
246
- addColumnIfMissing(connection, "issues", "branch_ownership_changed_at", "TEXT");
247
- connection.prepare("UPDATE issues SET branch_owner = 'patchrelay' WHERE branch_owner IS NULL OR branch_owner != 'patchrelay'").run();
248
244
  // Add merge_prep_attempts for retry budget / escalation
249
245
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
250
246
  // Add review_fix_attempts counter
@@ -304,7 +300,7 @@ function addColumnIfMissing(connection, table, column, definition) {
304
300
  function removeRetiredIssueColumnsIfPresent(connection) {
305
301
  const cols = connection.prepare("PRAGMA table_info(issues)").all();
306
302
  const columnNames = new Set(cols.map((column) => String(column.name)));
307
- const retired = ["queue_label_applied", "pending_merge_prep", "merge_prep_attempts"];
303
+ const retired = ["queue_label_applied", "pending_merge_prep", "merge_prep_attempts", "branch_owner", "branch_ownership_changed_at"];
308
304
  if (!retired.some((name) => columnNames.has(name))) {
309
305
  return;
310
306
  }
@@ -315,6 +311,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
315
311
  id INTEGER PRIMARY KEY AUTOINCREMENT,
316
312
  project_id TEXT NOT NULL,
317
313
  linear_issue_id TEXT NOT NULL,
314
+ delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
318
315
  issue_key TEXT,
319
316
  title TEXT,
320
317
  description TEXT,
@@ -327,8 +324,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
327
324
  pending_run_type TEXT,
328
325
  pending_run_context_json TEXT,
329
326
  branch_name TEXT,
330
- branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
331
- branch_ownership_changed_at TEXT,
332
327
  worktree_path TEXT,
333
328
  thread_id TEXT,
334
329
  active_run_id INTEGER,
@@ -371,6 +366,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
371
366
  id,
372
367
  project_id,
373
368
  linear_issue_id,
369
+ delegated_to_patchrelay,
374
370
  issue_key,
375
371
  title,
376
372
  description,
@@ -383,8 +379,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
383
379
  pending_run_type,
384
380
  pending_run_context_json,
385
381
  branch_name,
386
- branch_owner,
387
- branch_ownership_changed_at,
388
382
  worktree_path,
389
383
  thread_id,
390
384
  active_run_id,
@@ -425,6 +419,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
425
419
  id,
426
420
  project_id,
427
421
  linear_issue_id,
422
+ COALESCE(delegated_to_patchrelay, 1),
428
423
  issue_key,
429
424
  title,
430
425
  description,
@@ -437,8 +432,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
437
432
  pending_run_type,
438
433
  pending_run_context_json,
439
434
  branch_name,
440
- COALESCE(branch_owner, 'patchrelay'),
441
- branch_ownership_changed_at,
442
435
  worktree_path,
443
436
  thread_id,
444
437
  active_run_id,
package/dist/db.js CHANGED
@@ -34,6 +34,7 @@ function hasUnattemptedFailureSignature(issue, fallbackHeadSha) {
34
34
  }
35
35
  function deriveImplicitReactiveWake(issue) {
36
36
  const reactiveIntent = deriveIssueSessionReactiveIntent({
37
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
37
38
  activeRunId: issue.activeRunId,
38
39
  prNumber: issue.prNumber,
39
40
  prState: issue.prState,
@@ -144,9 +145,6 @@ export class PatchRelayDatabase {
144
145
  getIssueByPrNumber(prNumber) {
145
146
  return this.issues.getIssueByPrNumber(prNumber);
146
147
  }
147
- setBranchOwner(projectId, linearIssueId, owner) {
148
- this.issues.setBranchOwner(projectId, linearIssueId, owner);
149
- }
150
148
  replaceIssueDependencies(params) {
151
149
  this.issues.replaceIssueDependencies(params);
152
150
  }
@@ -0,0 +1,57 @@
1
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
2
+ import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
3
+ import { buildGitHubStateActivity } from "./linear-session-reporting.js";
4
+ export async function emitGitHubLinearActivity(params) {
5
+ const { issue, newState, event, linearProvider, logger, feed } = params;
6
+ if (!issue.agentSessionId)
7
+ return;
8
+ try {
9
+ const linear = await linearProvider.forProject(issue.projectId);
10
+ if (!linear?.createAgentActivity)
11
+ return;
12
+ const content = buildGitHubStateActivity(issue.factoryState, event);
13
+ if (!content)
14
+ return;
15
+ const allowEphemeral = content.type === "thought" || content.type === "action";
16
+ await linear.createAgentActivity({
17
+ agentSessionId: issue.agentSessionId,
18
+ content,
19
+ ...(allowEphemeral ? { ephemeral: false } : {}),
20
+ });
21
+ }
22
+ catch (error) {
23
+ const msg = error instanceof Error ? error.message : String(error);
24
+ logger.warn({ issueKey: issue.issueKey, newState, error: msg }, "Failed to emit Linear activity from GitHub webhook");
25
+ feed?.publish({
26
+ level: "warn",
27
+ kind: "linear",
28
+ issueKey: issue.issueKey,
29
+ projectId: issue.projectId,
30
+ status: "linear_error",
31
+ summary: `Linear activity failed: ${msg}`,
32
+ });
33
+ }
34
+ }
35
+ export async function syncGitHubLinearSession(params) {
36
+ const { issue, linearProvider, logger, config } = params;
37
+ if (!issue.agentSessionId)
38
+ return;
39
+ try {
40
+ const linear = await linearProvider.forProject(issue.projectId);
41
+ if (!linear?.updateAgentSession)
42
+ return;
43
+ const externalUrls = buildAgentSessionExternalUrls(config, {
44
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
45
+ ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
46
+ });
47
+ await linear.updateAgentSession({
48
+ agentSessionId: issue.agentSessionId,
49
+ plan: buildAgentSessionPlanForIssue(issue),
50
+ ...(externalUrls ? { externalUrls } : {}),
51
+ });
52
+ }
53
+ catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear session from GitHub webhook");
56
+ }
57
+ }
@@ -0,0 +1,74 @@
1
+ export class GitHubPrCommentHandler {
2
+ db;
3
+ enqueueIssue;
4
+ logger;
5
+ codex;
6
+ feed;
7
+ constructor(db, enqueueIssue, logger, codex, feed) {
8
+ this.db = db;
9
+ this.enqueueIssue = enqueueIssue;
10
+ this.logger = logger;
11
+ this.codex = codex;
12
+ this.feed = feed;
13
+ }
14
+ async handleCreatedComment(payload) {
15
+ if (payload.action !== "created")
16
+ return;
17
+ const issuePayload = payload.issue;
18
+ const comment = payload.comment;
19
+ if (!issuePayload || !comment)
20
+ return;
21
+ if (!issuePayload.pull_request)
22
+ return;
23
+ const body = typeof comment.body === "string" ? comment.body : "";
24
+ if (!body.trim())
25
+ return;
26
+ const user = comment.user;
27
+ const author = typeof user?.login === "string" ? user.login : "unknown";
28
+ if (typeof user?.type === "string" && user.type === "Bot")
29
+ return;
30
+ const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
31
+ if (!prNumber)
32
+ return;
33
+ const issue = this.db.issues.getIssueByPrNumber(prNumber);
34
+ if (!issue)
35
+ return;
36
+ this.feed?.publish({
37
+ level: "info",
38
+ kind: "comment",
39
+ issueKey: issue.issueKey,
40
+ projectId: issue.projectId,
41
+ stage: issue.factoryState,
42
+ status: "pr_comment",
43
+ summary: `GitHub PR comment from ${author}`,
44
+ detail: body.slice(0, 200),
45
+ });
46
+ if (issue.activeRunId) {
47
+ const run = this.db.runs.getRunById(issue.activeRunId);
48
+ if (run?.threadId && run.turnId) {
49
+ try {
50
+ await this.codex.steerTurn({
51
+ threadId: run.threadId,
52
+ turnId: run.turnId,
53
+ input: `GitHub PR comment from ${author}:\n\n${body}`,
54
+ });
55
+ this.logger.info({ issueKey: issue.issueKey, author }, "Forwarded GitHub PR comment to active run");
56
+ return;
57
+ }
58
+ catch (error) {
59
+ const msg = error instanceof Error ? error.message : String(error);
60
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to forward GitHub PR comment");
61
+ }
62
+ }
63
+ }
64
+ this.db.issueSessions.appendIssueSessionEvent({
65
+ projectId: issue.projectId,
66
+ linearIssueId: issue.linearIssueId,
67
+ eventType: "followup_comment",
68
+ eventJson: JSON.stringify({ body, author }),
69
+ });
70
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
71
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
72
+ }
73
+ }
74
+ }