patchrelay 0.37.0 → 0.38.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.
Files changed (48) 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 +64 -5
  5. package/dist/cli/data.js +1 -0
  6. package/dist/cli/formatters/text.js +5 -1
  7. package/dist/cli/help.js +1 -1
  8. package/dist/cli/output.js +2 -0
  9. package/dist/cli/watch/IssueRow.js +4 -3
  10. package/dist/cli/watch/StatusBar.js +2 -1
  11. package/dist/cli/watch/detail-rows.js +4 -3
  12. package/dist/cli/watch/pr-status.js +2 -1
  13. package/dist/cli/watch/state-visualization.js +5 -1
  14. package/dist/db/issue-session-store.js +0 -14
  15. package/dist/db/issue-store.js +8 -16
  16. package/dist/db/migrations.js +6 -13
  17. package/dist/db.js +1 -3
  18. package/dist/factory-state.js +1 -1
  19. package/dist/github-webhook-handler.js +95 -54
  20. package/dist/github-webhooks.js +4 -0
  21. package/dist/idle-reconciliation.js +38 -22
  22. package/dist/implementation-outcome-policy.js +3 -1
  23. package/dist/issue-overview-query.js +8 -0
  24. package/dist/issue-session-projector.js +1 -0
  25. package/dist/issue-session.js +8 -0
  26. package/dist/linear-session-reporting.js +43 -5
  27. package/dist/linear-session-sync.js +9 -1
  28. package/dist/linear-status-comment-sync.js +47 -2
  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/pr-state.js +49 -0
  33. package/dist/run-launcher.js +0 -1
  34. package/dist/run-orchestrator.js +2 -5
  35. package/dist/run-reconciler.js +10 -0
  36. package/dist/run-recovery-service.js +1 -10
  37. package/dist/service-issue-actions.js +10 -4
  38. package/dist/service-startup-recovery.js +9 -6
  39. package/dist/service.js +0 -1
  40. package/dist/tracked-issue-list-query.js +6 -2
  41. package/dist/tracked-issue-projector.js +8 -0
  42. package/dist/waiting-reason.js +13 -2
  43. package/dist/webhooks/agent-session-handler.js +9 -1
  44. package/dist/webhooks/comment-wake-handler.js +12 -0
  45. package/dist/webhooks/decision-helpers.js +44 -3
  46. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  47. package/dist/webhooks/desired-stage-recorder.js +40 -10
  48. 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.0",
4
- "commit": "e927a73f2d7b",
5
- "builtAt": "2026-04-10T13:26:32.780Z"
3
+ "version": "0.38.0",
4
+ "commit": "ec38527e2d4f",
5
+ "builtAt": "2026-04-10T17:39:33.237Z"
6
6
  }
@@ -1,5 +1,6 @@
1
1
  import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
2
2
  import { ACTIVE_RUN_STATES } from "../factory-state.js";
3
+ import { hasOpenPr, resolveClosedPrDisposition } from "../pr-state.js";
3
4
  const RECONCILIATION_GRACE_MS = 120_000;
4
5
  const DOWNSTREAM_STALE_MS = 900_000;
5
6
  export async function collectClusterHealth(config, db, runCommand) {
@@ -98,7 +99,7 @@ export async function collectClusterHealth(config, db, runCommand) {
98
99
  }
99
100
  checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
100
101
  for (const snapshot of snapshots) {
101
- if (!snapshot.issue.prNumber) {
102
+ if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
102
103
  continue;
103
104
  }
104
105
  const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
@@ -277,8 +278,31 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
277
278
  const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
278
279
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
279
280
  const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
281
+ if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
282
+ return {
283
+ finding: {
284
+ status: "fail",
285
+ scope: "github:reconcile",
286
+ message: "PR is already merged but the issue has not advanced to done",
287
+ },
288
+ };
289
+ }
290
+ if (pr.state === "CLOSED") {
291
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
292
+ if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
293
+ return {
294
+ finding: {
295
+ status: "fail",
296
+ scope: "github:reconcile",
297
+ message: "PR is closed but unfinished work has not been re-delegated",
298
+ },
299
+ };
300
+ }
301
+ return {};
302
+ }
280
303
  const ciEntry = buildCiEntry({
281
304
  issue,
305
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
282
306
  gateCheckStatus,
283
307
  reviewDecision,
284
308
  reviewRequested,
@@ -307,7 +331,11 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
307
331
  },
308
332
  };
309
333
  }
310
- if (gateCheckStatus === "failure" && issue.factoryState !== "repairing_ci" && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
334
+ if (issue.delegatedToPatchRelay
335
+ && gateCheckStatus === "failure"
336
+ && issue.factoryState !== "repairing_ci"
337
+ && issue.activeRunId === undefined
338
+ && ageMs >= RECONCILIATION_GRACE_MS) {
311
339
  return {
312
340
  ciEntry,
313
341
  finding: {
@@ -330,6 +358,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
330
358
  if (gateCheckStatus === "success"
331
359
  && reviewDecision === "CHANGES_REQUESTED"
332
360
  && mergeConflictDetected
361
+ && issue.delegatedToPatchRelay
333
362
  && issue.factoryState !== "changes_requested"
334
363
  && issue.activeRunId === undefined
335
364
  && ageMs >= RECONCILIATION_GRACE_MS) {
@@ -346,6 +375,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
346
375
  && reviewDecision === "CHANGES_REQUESTED"
347
376
  && latestBlockingReviewHeadSha === pr.headRefOid
348
377
  && !reviewQuillAttempt
378
+ && issue.delegatedToPatchRelay
349
379
  && issue.factoryState !== "changes_requested"
350
380
  && ageMs >= RECONCILIATION_GRACE_MS) {
351
381
  return {
@@ -367,7 +397,11 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
367
397
  },
368
398
  };
369
399
  }
370
- 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) {
371
405
  return {
372
406
  ciEntry,
373
407
  finding: {
@@ -390,8 +424,9 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
390
424
  return { ciEntry };
391
425
  }
392
426
  function buildCiEntry(params) {
393
- const { issue, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
427
+ const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
394
428
  const owner = deriveCiOwner({
429
+ delegatedToPatchRelay,
395
430
  gateCheckStatus,
396
431
  factoryState: issue.factoryState,
397
432
  reviewDecision,
@@ -411,6 +446,7 @@ function buildCiEntry(params) {
411
446
  factoryState: issue.factoryState,
412
447
  ...(reviewDecision ? { reviewDecision } : {}),
413
448
  message: describeCiOwnership({
449
+ delegatedToPatchRelay,
414
450
  gateCheckStatus,
415
451
  owner,
416
452
  reviewDecision,
@@ -427,20 +463,29 @@ function deriveCiOwner(params) {
427
463
  && params.latestBlockingReviewHeadSha
428
464
  && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
429
465
  if (params.gateCheckStatus === "failure") {
466
+ if (!params.delegatedToPatchRelay)
467
+ return "paused";
430
468
  return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
431
469
  }
432
470
  if (params.gateCheckStatus === "pending") {
433
471
  return "external";
434
472
  }
435
473
  if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
474
+ if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
475
+ return "paused";
476
+ }
436
477
  return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
437
478
  ? "unknown"
438
479
  : "downstream";
439
480
  }
440
481
  if (params.reviewDecision === "CHANGES_REQUESTED") {
441
482
  if (params.mergeConflictDetected) {
483
+ if (!params.delegatedToPatchRelay)
484
+ return "paused";
442
485
  return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
443
486
  }
487
+ if (!params.delegatedToPatchRelay)
488
+ return "paused";
444
489
  if (params.factoryState === "changes_requested")
445
490
  return "patchrelay";
446
491
  if (params.reviewQuillAttempt)
@@ -499,6 +544,20 @@ function describeCiOwnership(params) {
499
544
  ? "Waiting on external CI checks to settle"
500
545
  : "Waiting on external GitHub automation";
501
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
+ }
502
561
  if (params.reviewDecision === "CHANGES_REQUESTED") {
503
562
  if (params.mergeConflictDetected) {
504
563
  return headAdvancedPastBlockingReview
@@ -521,7 +580,7 @@ function needsReviewAutomation(issue) {
521
580
  if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
522
581
  return false;
523
582
  }
524
- return issue.prNumber !== undefined;
583
+ return hasOpenPr(issue.prNumber, issue.prState);
525
584
  }
526
585
  async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
527
586
  const owners = new Map();
package/dist/cli/data.js CHANGED
@@ -119,6 +119,7 @@ export class CliDataAccess extends CliOperatorApiClient {
119
119
  ...(latestReport ? { latestReport } : {}),
120
120
  ...(latestSummary ? { latestSummary } : {}),
121
121
  ...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
122
+ ...(dbIssue.prState ? { prState: dbIssue.prState } : {}),
122
123
  ...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
123
124
  ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
124
125
  ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
@@ -20,7 +20,11 @@ export function formatInspect(result) {
20
20
  value("Debug stage", result.issue?.factoryState),
21
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
22
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
23
- result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
23
+ result.prNumber
24
+ ? value("PR", `#${result.prNumber}${result.prState || result.prReviewState
25
+ ? ` [${[result.prState, result.prReviewState].filter(Boolean).join(", ")}]`
26
+ : ""}`)
27
+ : undefined,
24
28
  result.completionCheckOutcome ? value("Completion check", result.completionCheckOutcome) : undefined,
25
29
  result.completionCheckSummary ? value("Completion summary", truncateLine(result.completionCheckSummary)) : undefined,
26
30
  result.completionCheckQuestion ? value("Question", truncateLine(result.completionCheckQuestion)) : undefined,
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
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
5
  import { relativeTime, truncate } from "./format-utils.js";
5
6
  import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
@@ -19,7 +20,7 @@ function effectiveState(issue) {
19
20
  return "blocked";
20
21
  if (issue.sessionState === "waiting_input")
21
22
  return "awaiting_input";
22
- if (issue.prNumber !== undefined)
23
+ if (hasOpenPr(issue.prNumber, issue.prState))
23
24
  return issue.factoryState;
24
25
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
25
26
  return "ready";
@@ -94,7 +95,7 @@ function buildFacts(issue, selected) {
94
95
  else if (isChangesRequestedReviewState(issue.prReviewState)) {
95
96
  facts.push({ text: "changes requested", color: "yellow" });
96
97
  }
97
- else if (issue.prNumber !== undefined
98
+ else if (hasOpenPr(issue.prNumber, issue.prState)
98
99
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
99
100
  facts.push({ text: "awaiting review", color: "yellow" });
100
101
  }
@@ -146,7 +147,7 @@ function blockerText(issue) {
146
147
  return "Awaiting re-review after requested changes";
147
148
  if (isChangesRequestedReviewState(issue.prReviewState))
148
149
  return "Review changes requested";
149
- if (issue.prNumber !== undefined && isAwaitingReviewState(issue.prReviewState))
150
+ if (hasOpenPr(issue.prNumber, issue.prState) && isAwaitingReviewState(issue.prReviewState))
150
151
  return "Awaiting review";
151
152
  return null;
152
153
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { computeAggregates } from "./watch-state.js";
4
5
  import { FreshnessBadge } from "./FreshnessBadge.js";
5
6
  const FILTER_LABELS = {
@@ -11,7 +12,7 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
11
12
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
13
  const aggregateSource = filter === "all" ? allIssues : issues;
13
14
  const agg = computeAggregates(aggregateSource);
14
- const withPr = aggregateSource.filter((i) => i.prNumber !== undefined).length;
15
+ const withPr = aggregateSource.filter((i) => hasOpenPr(i.prNumber, i.prState)).length;
15
16
  const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
16
17
  const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
17
18
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  import { buildStateHistory } from "./history-builder.js";
2
3
  import { buildTimelineRows } from "./timeline-presentation.js";
3
4
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
@@ -396,7 +397,7 @@ function buildFactSegments(issue, issueContext) {
396
397
  facts.push([{ text: "re-review needed", color: "yellow" }]);
397
398
  else if (isChangesRequestedReviewState(issue.prReviewState))
398
399
  facts.push([{ text: "changes requested", color: "yellow" }]);
399
- else if (issue.prNumber !== undefined
400
+ else if (hasOpenPr(issue.prNumber, issue.prState)
400
401
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
401
402
  facts.push([{ text: "awaiting review", color: "yellow" }]);
402
403
  if (issue.factoryState === "awaiting_queue")
@@ -503,7 +504,7 @@ function effectiveState(issue) {
503
504
  return "blocked";
504
505
  if (issue.sessionState === "waiting_input")
505
506
  return "awaiting_input";
506
- if (issue.prNumber !== undefined)
507
+ if (hasOpenPr(issue.prNumber, issue.prState))
507
508
  return issue.factoryState;
508
509
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
509
510
  return "ready";
@@ -546,7 +547,7 @@ function blockerText(issue, issueContext) {
546
547
  return "Awaiting re-review after requested changes";
547
548
  if (isChangesRequestedReviewState(issue.prReviewState))
548
549
  return "Review changes requested";
549
- if (issue.prNumber !== undefined && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
550
+ if (hasOpenPr(issue.prNumber, issue.prState) && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
550
551
  return "Awaiting review";
551
552
  }
552
553
  return null;
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  function isPassingCheckStatus(status) {
2
3
  return status === "passed" || status === "success";
3
4
  }
@@ -72,7 +73,7 @@ export function prChecksFact(issue) {
72
73
  return undefined;
73
74
  }
74
75
  export function hasDisplayPrBlocker(issue) {
75
- if (issue.prNumber === undefined || issue.activeRunType) {
76
+ if (!hasOpenPr(issue.prNumber, issue.prState) || issue.activeRunType) {
76
77
  return false;
77
78
  }
78
79
  if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  const STATE_LABELS = {
2
3
  delegated: "delegated",
3
4
  implementing: "implementing",
@@ -163,9 +164,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
163
164
  });
164
165
  }
165
166
  if (issue.prNumber !== undefined) {
167
+ const prLabel = hasOpenPr(issue.prNumber, issue.prState)
168
+ ? `Tracked PR: #${issue.prNumber}`
169
+ : `Tracked PR: #${issue.prNumber}${issue.prState ? ` (${issue.prState})` : ""}`;
166
170
  observations.push({
167
171
  tone: "info",
168
- text: `Tracked PR: #${issue.prNumber}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
172
+ text: `${prLabel}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
169
173
  });
170
174
  }
171
175
  return observations.slice(0, 3);
@@ -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
  }
@@ -30,7 +30,7 @@ const TRANSITION_RULES = [
30
30
  // pr_closed during an active run is suppressed — Codex may reopen.
31
31
  // Without a guard match, the event produces no transition (undefined).
32
32
  { event: "pr_closed",
33
- guard: (_, ctx) => ctx.activeRunId === undefined,
33
+ guard: (s, ctx) => ctx.activeRunId === undefined && !TERMINAL_STATES.has(s),
34
34
  to: "failed" },
35
35
  // ── PR lifecycle ───────────────────────────────────────────────
36
36
  { event: "pr_opened",