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
@@ -1,4 +1,4 @@
1
- import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
1
+ import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
3
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
@@ -7,6 +7,7 @@ import { buildGitHubStateActivity } from "./linear-session-reporting.js";
7
7
  import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
8
8
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
9
9
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
10
+ import { buildClosedPrCleanupFields, isIssueTerminal, resolveClosedPrFactoryState, resolveClosedPrDisposition, } from "./pr-state.js";
10
11
  import { resolveSecret } from "./resolve-secret.js";
11
12
  import { safeJsonParse } from "./utils.js";
12
13
  /**
@@ -33,7 +34,6 @@ export class GitHubWebhookHandler {
33
34
  failureContextResolver;
34
35
  ciSnapshotResolver;
35
36
  fetchImpl;
36
- patchRelayAuthorLogins = new Set();
37
37
  constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
38
38
  this.config = config;
39
39
  this.db = db;
@@ -45,18 +45,6 @@ export class GitHubWebhookHandler {
45
45
  this.failureContextResolver = failureContextResolver;
46
46
  this.ciSnapshotResolver = ciSnapshotResolver;
47
47
  this.fetchImpl = fetchImpl;
48
- for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
49
- this.patchRelayAuthorLogins.add(login);
50
- }
51
- }
52
- setPatchRelayAuthorLogins(logins) {
53
- this.patchRelayAuthorLogins.clear();
54
- for (const login of logins) {
55
- const normalized = normalizeAuthorLogin(login);
56
- if (normalized) {
57
- this.patchRelayAuthorLogins.add(normalized);
58
- }
59
- }
60
48
  }
61
49
  async acceptGitHubWebhook(params) {
62
50
  // Deduplicate
@@ -129,13 +117,17 @@ export class GitHubWebhookHandler {
129
117
  this.logger.debug({ eventType: params.eventType }, "GitHub webhook: unrecognized event type or action");
130
118
  return;
131
119
  }
132
- // Route to issue via branch name
133
- const issue = this.db.issues.getIssueByBranch(event.branchName);
120
+ const project = this.config.projects.find((candidate) => candidate.github?.repoFullName === event.repoFullName);
121
+ if (!project) {
122
+ this.logger.debug({ repoFullName: event.repoFullName, triggerEvent: event.triggerEvent }, "GitHub webhook: no configured project for repository");
123
+ return;
124
+ }
125
+ const resolved = this.resolveIssueForEvent(project, event);
126
+ const issue = resolved?.issue;
134
127
  if (!issue) {
135
- this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
128
+ this.logger.debug({ repoFullName: event.repoFullName, branchName: event.branchName, prNumber: event.prNumber, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching tracked issue");
136
129
  return;
137
130
  }
138
- const project = this.config.projects.find((p) => p.id === issue.projectId);
139
131
  const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
140
132
  // Update PR state on the issue
141
133
  this.db.issues.upsertIssue({
@@ -148,11 +140,15 @@ export class GitHubWebhookHandler {
148
140
  ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
149
141
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
150
142
  ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
143
+ ...(resolved.linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
151
144
  ...(event.reviewState === "changes_requested"
152
145
  ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
153
146
  : event.reviewState === "approved"
154
147
  ? { lastBlockingReviewHeadSha: null }
155
148
  : {}),
149
+ ...(event.triggerEvent === "pr_closed"
150
+ ? buildClosedPrCleanupFields()
151
+ : {}),
156
152
  });
157
153
  await this.updateCiSnapshot(issue, event, project);
158
154
  await this.updateFailureProvenance(issue, event, project);
@@ -226,18 +222,75 @@ export class GitHubWebhookHandler {
226
222
  await this.handleTerminalPrEvent(freshIssue, event);
227
223
  }
228
224
  }
225
+ resolveIssueForEvent(project, event) {
226
+ if (event.prNumber !== undefined) {
227
+ const byPr = this.db.issues.getIssueByPrNumber(event.prNumber);
228
+ if (byPr && byPr.projectId === project.id) {
229
+ return { issue: byPr, linkedBy: "pr" };
230
+ }
231
+ }
232
+ const byBranch = this.db.issues.getIssueByBranch(event.branchName);
233
+ if (byBranch && byBranch.projectId === project.id) {
234
+ return { issue: byBranch, linkedBy: "branch" };
235
+ }
236
+ const byIssueKey = this.resolveIssueByExplicitIssueKey(project, event);
237
+ if (byIssueKey) {
238
+ return { issue: byIssueKey, linkedBy: "issue_key" };
239
+ }
240
+ return undefined;
241
+ }
242
+ resolveIssueByExplicitIssueKey(project, event) {
243
+ const candidates = new Set();
244
+ const sources = [event.prTitle, event.prBody, event.branchName];
245
+ for (const prefix of project.issueKeyPrefixes) {
246
+ const normalizedPrefix = prefix.trim();
247
+ if (!normalizedPrefix)
248
+ continue;
249
+ const pattern = new RegExp(`\\b${escapeRegExp(normalizedPrefix)}-\\d+\\b`, "gi");
250
+ for (const source of sources) {
251
+ if (!source)
252
+ continue;
253
+ for (const match of source.matchAll(pattern)) {
254
+ candidates.add(match[0].toUpperCase());
255
+ }
256
+ }
257
+ }
258
+ if (candidates.size !== 1) {
259
+ return undefined;
260
+ }
261
+ const [issueKey] = [...candidates];
262
+ if (!issueKey) {
263
+ return undefined;
264
+ }
265
+ const issue = this.db.issues.getIssueByKey(issueKey);
266
+ return issue?.projectId === project.id ? issue : undefined;
267
+ }
229
268
  resolveFactoryStateForEvent(issue, event, project) {
269
+ if (event.triggerEvent === "pr_closed") {
270
+ return undefined;
271
+ }
272
+ const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
273
+ && (event.prState === "open" || event.prNumber !== undefined)
274
+ ? "pr_open"
275
+ : issue.factoryState;
230
276
  if (event.triggerEvent === "check_failed"
231
277
  && this.isQueueEvictionFailure(issue, event, project)
232
278
  && issue.prState === "open"
233
279
  && issue.activeRunId === undefined
234
- && !TERMINAL_STATES.has(issue.factoryState)) {
280
+ && !isIssueTerminal(issue)) {
235
281
  return "repairing_queue";
236
282
  }
237
- return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
283
+ const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
238
284
  prReviewState: issue.prReviewState,
239
285
  activeRunId: issue.activeRunId,
240
286
  });
287
+ if (resolved !== undefined) {
288
+ return resolved;
289
+ }
290
+ if (effectiveCurrentState !== issue.factoryState) {
291
+ return effectiveCurrentState;
292
+ }
293
+ return undefined;
241
294
  }
242
295
  async updateCiSnapshot(issue, event, project) {
243
296
  if (event.triggerEvent === "pr_merged") {
@@ -318,17 +371,17 @@ export class GitHubWebhookHandler {
318
371
  return;
319
372
  // Don't trigger on terminal issues — late-arriving webhooks (e.g.
320
373
  // merge_group_failed after pr_merged) must not resurrect done issues.
321
- if (TERMINAL_STATES.has(issue.factoryState))
374
+ if (isIssueTerminal(issue))
322
375
  return;
323
- if (!this.isPatchRelayOwnedPr(issue)) {
376
+ if (!issue.delegatedToPatchRelay) {
324
377
  this.feed?.publish({
325
378
  level: "info",
326
379
  kind: "github",
327
380
  issueKey: issue.issueKey,
328
381
  projectId: issue.projectId,
329
382
  stage: issue.factoryState,
330
- status: "ignored_non_patchrelay_pr",
331
- summary: `Ignored ${event.triggerEvent} on non-PatchRelay-owned PR`,
383
+ status: "ignored_undelegated",
384
+ summary: `Ignored ${event.triggerEvent} because the issue is undelegated`,
332
385
  });
333
386
  return;
334
387
  }
@@ -365,7 +418,6 @@ export class GitHubWebhookHandler {
365
418
  }),
366
419
  dedupeKey: failureContext.failureSignature,
367
420
  });
368
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
369
421
  const queuedRunType = hadPendingWake
370
422
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
371
423
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -423,7 +475,6 @@ export class GitHubWebhookHandler {
423
475
  }),
424
476
  dedupeKey: failureContext.failureSignature,
425
477
  });
426
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
427
478
  const queuedRunType = hadPendingWake
428
479
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
429
480
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -469,7 +520,6 @@ export class GitHubWebhookHandler {
469
520
  event.reviewerName ?? "unknown-reviewer",
470
521
  ].join("::"),
471
522
  });
472
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
473
523
  const queuedRunType = hadPendingWake
474
524
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
475
525
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -521,11 +571,14 @@ export class GitHubWebhookHandler {
521
571
  : "Pull request closed during active run",
522
572
  });
523
573
  }
574
+ const terminalFactoryState = event.triggerEvent === "pr_merged"
575
+ ? "done"
576
+ : resolveClosedPrFactoryState(issue);
524
577
  this.db.issues.upsertIssue({
525
578
  projectId: issue.projectId,
526
579
  linearIssueId: issue.linearIssueId,
527
580
  activeRunId: null,
528
- factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
581
+ factoryState: terminalFactoryState,
529
582
  });
530
583
  };
531
584
  const activeLease = this.db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
@@ -537,6 +590,17 @@ export class GitHubWebhookHandler {
537
590
  }
538
591
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
539
592
  const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
593
+ if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
594
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
595
+ projectId: issue.projectId,
596
+ linearIssueId: issue.linearIssueId,
597
+ eventType: "delegated",
598
+ dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
599
+ });
600
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
601
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
602
+ }
603
+ }
540
604
  if (event.triggerEvent === "pr_merged") {
541
605
  await this.completeLinearIssueAfterMerge(updatedIssue);
542
606
  }
@@ -687,7 +751,7 @@ export class GitHubWebhookHandler {
687
751
  if (!signature)
688
752
  return false;
689
753
  const pendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
690
- if (pendingWake?.runType === runType) {
754
+ if (pendingWake?.runType === runType && pendingWake.eventIds.length > 0) {
691
755
  const existing = pendingWake.context;
692
756
  if (existing?.failureSignature === signature
693
757
  && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
@@ -923,8 +987,6 @@ export class GitHubWebhookHandler {
923
987
  const issue = this.db.issues.getIssueByPrNumber(prNumber);
924
988
  if (!issue)
925
989
  return;
926
- if (!this.isPatchRelayOwnedPr(issue))
927
- return;
928
990
  this.feed?.publish({
929
991
  level: "info",
930
992
  kind: "comment",
@@ -987,30 +1049,9 @@ export class GitHubWebhookHandler {
987
1049
  this.enqueueIssue(projectId, issueId);
988
1050
  return wake.runType;
989
1051
  }
990
- isPatchRelayOwnedPr(issue) {
991
- const author = normalizeAuthorLogin(issue.prAuthorLogin);
992
- if (author) {
993
- if (this.patchRelayAuthorLogins.size > 0) {
994
- return this.patchRelayAuthorLogins.has(author);
995
- }
996
- return author.includes("patchrelay");
997
- }
998
- // Transitional fallback for rows written before author tracking existed.
999
- return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
1000
- }
1001
- }
1002
- function normalizeAuthorLogin(login) {
1003
- const normalized = login?.trim().toLowerCase();
1004
- return normalized ? normalized : undefined;
1005
1052
  }
1006
- function resolvePatchRelayAuthorLoginsFromEnv() {
1007
- return [
1008
- process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
1009
- process.env.PATCHRELAY_GITHUB_BOT_NAME,
1010
- ]
1011
- .flatMap((value) => (value ?? "").split(","))
1012
- .map((value) => normalizeAuthorLogin(value))
1013
- .filter((value) => Boolean(value));
1053
+ function escapeRegExp(value) {
1054
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1014
1055
  }
1015
1056
  function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
1016
1057
  if (!repoFullName || prNumber === undefined || reviewId === undefined) {
@@ -61,6 +61,8 @@ function normalizePullRequestEvent(payload, repoFullName) {
61
61
  repoFullName,
62
62
  branchName: pr.head.ref,
63
63
  headSha: pr.head.sha,
64
+ prTitle: pr.title ?? undefined,
65
+ prBody: pr.body ?? undefined,
64
66
  prNumber: pr.number,
65
67
  prUrl: pr.html_url,
66
68
  prState,
@@ -98,6 +100,8 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
98
100
  repoFullName,
99
101
  branchName: pr.head.ref,
100
102
  headSha: pr.head.sha,
103
+ prTitle: pr.title ?? undefined,
104
+ prBody: pr.body ?? undefined,
101
105
  prNumber: pr.number,
102
106
  prUrl: pr.html_url,
103
107
  prState: "open",
@@ -3,6 +3,7 @@ import { parseGitHubFailureContext } from "./github-failure-context.js";
3
3
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
+ import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
6
7
  import { execCommand } from "./utils.js";
7
8
  const DEFAULT_REVIEW_FIX_BUDGET = 12;
8
9
  function isFailingCheckStatus(status) {
@@ -88,15 +89,6 @@ function hasFailureProvenance(issue) {
88
89
  || issue.lastAttemptedFailureHeadSha
89
90
  || issue.lastAttemptedFailureSignature);
90
91
  }
91
- export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
92
- if (pendingRunType)
93
- return "patchrelay";
94
- if (newState === "awaiting_queue")
95
- return "patchrelay";
96
- if (newState === "repairing_ci" || newState === "repairing_queue")
97
- return "patchrelay";
98
- return undefined;
99
- }
100
92
  export class IdleIssueReconciler {
101
93
  db;
102
94
  config;
@@ -152,6 +144,8 @@ export class IdleIssueReconciler {
152
144
  await this.reconcileFromGitHub(issue);
153
145
  }
154
146
  for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
147
+ if (!issue.delegatedToPatchRelay)
148
+ continue;
155
149
  const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
156
150
  if (unresolved === 0) {
157
151
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
@@ -205,10 +199,6 @@ export class IdleIssueReconciler {
205
199
  }
206
200
  : {}),
207
201
  });
208
- const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
209
- if (branchOwner) {
210
- this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
211
- }
212
202
  if (options?.pendingRunType) {
213
203
  this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
214
204
  }
@@ -253,6 +243,9 @@ export class IdleIssueReconciler {
253
243
  });
254
244
  }
255
245
  async routeFailedIssue(issue) {
246
+ if (!issue.delegatedToPatchRelay) {
247
+ return;
248
+ }
256
249
  issue = await this.refreshMissingFailureProvenance(issue);
257
250
  issue = await this.reclassifyStaleBranchFailure(issue);
258
251
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
@@ -439,12 +432,33 @@ export class IdleIssueReconciler {
439
432
  return;
440
433
  }
441
434
  if (pr.state === "CLOSED") {
442
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed, re-delegating for implementation");
443
- this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
444
- this.advanceIdleIssue(issue, "delegated", {
445
- pendingRunType: "implementation",
446
- clearFailureProvenance: true,
435
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
436
+ this.db.issues.upsertIssue({
437
+ projectId: issue.projectId,
438
+ linearIssueId: issue.linearIssueId,
439
+ prState: "closed",
440
+ ...buildClosedPrCleanupFields(),
447
441
  });
442
+ if (closedPrDisposition === "done") {
443
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
444
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
445
+ return;
446
+ }
447
+ if (closedPrDisposition === "terminal") {
448
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
449
+ return;
450
+ }
451
+ if (issue.delegatedToPatchRelay) {
452
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished delegated work, re-delegating for implementation");
453
+ this.advanceIdleIssue(issue, "delegated", {
454
+ pendingRunType: "implementation",
455
+ clearFailureProvenance: true,
456
+ });
457
+ }
458
+ else {
459
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed while undelegated; preserving paused local-work state");
460
+ this.advanceIdleIssue(issue, "delegated", { clearFailureProvenance: true });
461
+ }
448
462
  return;
449
463
  }
450
464
  const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
@@ -464,7 +478,8 @@ export class IdleIssueReconciler {
464
478
  return;
465
479
  }
466
480
  }
467
- if (isReviewDecisionReviewRequired(pr.reviewDecision)
481
+ if (issue.delegatedToPatchRelay
482
+ && isReviewDecisionReviewRequired(pr.reviewDecision)
468
483
  && gateCheckStatus === "success"
469
484
  && hasCompletedReviewQuillVerdict(pr.statusCheckRollup)) {
470
485
  this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewDecision: pr.reviewDecision }, "Reconciliation: review-quill completed without a decisive GitHub review; escalating for operator input");
@@ -492,7 +507,8 @@ export class IdleIssueReconciler {
492
507
  mergeConflictDetected,
493
508
  downstreamOwned,
494
509
  });
495
- if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
510
+ if (issue.delegatedToPatchRelay
511
+ && (issue.factoryState === "escalated" || issue.factoryState === "failed")
496
512
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
497
513
  if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
498
514
  this.logger.debug({
@@ -521,7 +537,7 @@ export class IdleIssueReconciler {
521
537
  });
522
538
  return;
523
539
  }
524
- if (reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
540
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
525
541
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
526
542
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
527
543
  pendingRunType: reactiveIntent.runType,
@@ -538,7 +554,7 @@ export class IdleIssueReconciler {
538
554
  });
539
555
  return;
540
556
  }
541
- if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
557
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
542
558
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
543
559
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
544
560
  pendingRunType: reactiveIntent.runType,
@@ -46,7 +46,9 @@ export class ImplementationOutcomePolicy {
46
46
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
47
47
  ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
48
48
  }, "published PR verification refresh");
49
- return undefined;
49
+ if (pr.state?.toLowerCase() !== "closed") {
50
+ return undefined;
51
+ }
50
52
  }
51
53
  }
52
54
  }
@@ -138,11 +138,13 @@ export class IssueOverviewQuery {
138
138
  const liveThread = await this.readLiveThread(activeRun);
139
139
  const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
140
140
  const waitingReason = session.waitingReason ?? derivePatchRelayWaitingReason({
141
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
141
142
  ...(activeRun ? { activeRunType: activeRun.runType } : {}),
142
143
  blockedByKeys,
143
144
  factoryState: issueRecord?.factoryState ?? "delegated",
144
145
  pendingRunType: issueRecord?.pendingRunType,
145
146
  prNumber: session.prNumber,
147
+ prState: issueRecord?.prState,
146
148
  prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
147
149
  prReviewState: issueRecord?.prReviewState,
148
150
  prCheckStatus: issueRecord?.prCheckStatus,
@@ -153,17 +155,23 @@ export class IssueOverviewQuery {
153
155
  id: issueRecord?.id ?? session.id,
154
156
  projectId: session.projectId,
155
157
  linearIssueId: session.linearIssueId,
158
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay ?? true,
156
159
  ...(session.issueKey ? { issueKey: session.issueKey } : {}),
157
160
  ...(issueRecord?.title ? { title: issueRecord.title } : {}),
158
161
  ...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
159
162
  ...(issueRecord?.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
160
163
  sessionState: session.sessionState,
161
164
  factoryState: issueRecord?.factoryState ?? "delegated",
165
+ ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
166
+ ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
167
+ ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
168
+ ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
162
169
  blockedByCount: unresolvedBlockedBy.length,
163
170
  blockedByKeys,
164
171
  readyForExecution: isIssueSessionReadyForExecution({
165
172
  sessionState: session.sessionState,
166
173
  factoryState: issueRecord?.factoryState ?? "delegated",
174
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
167
175
  ...(activeRun ? { activeRunId: activeRun.id } : {}),
168
176
  blockedByCount: unresolvedBlockedBy.length,
169
177
  hasPendingWake: this.db.issueSessions.peekIssueSessionWake(session.projectId, session.linearIssueId) !== undefined,
@@ -26,6 +26,7 @@ export function syncIssueSessionFromIssue(params) {
26
26
  });
27
27
  const lastWakeReason = options?.lastWakeReason
28
28
  ?? deriveIssueSessionWakeReason({
29
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
29
30
  pendingRunType: issue.pendingRunType,
30
31
  factoryState: issue.factoryState,
31
32
  prNumber: issue.prNumber,
@@ -14,6 +14,8 @@ export function deriveIssueSessionWaitingReason(params) {
14
14
  return derivePatchRelayWaitingReason(params);
15
15
  }
16
16
  export function deriveIssueSessionWakeReason(params) {
17
+ if (params.delegatedToPatchRelay === false)
18
+ return undefined;
17
19
  if (params.pendingRunType === "implementation")
18
20
  return "delegated";
19
21
  if (params.pendingRunType === "review_fix")
@@ -27,6 +29,7 @@ export function deriveIssueSessionWakeReason(params) {
27
29
  if (params.factoryState === "awaiting_input")
28
30
  return "waiting_for_human_reply";
29
31
  const reactiveIntent = deriveIssueSessionReactiveIntent({
32
+ delegatedToPatchRelay: params.delegatedToPatchRelay,
30
33
  prNumber: params.prNumber,
31
34
  prState: params.prState,
32
35
  prReviewState: params.prReviewState,
@@ -38,6 +41,8 @@ export function deriveIssueSessionWakeReason(params) {
38
41
  return undefined;
39
42
  }
40
43
  export function deriveIssueSessionReactiveIntent(params) {
44
+ if (params.delegatedToPatchRelay === false)
45
+ return undefined;
41
46
  if (params.activeRunId !== undefined)
42
47
  return undefined;
43
48
  if (params.prNumber === undefined)
@@ -75,6 +80,8 @@ export function deriveIssueSessionReactiveIntent(params) {
75
80
  return undefined;
76
81
  }
77
82
  export function isIssueSessionReadyForExecution(params) {
83
+ if (params.delegatedToPatchRelay === false)
84
+ return false;
78
85
  if (params.activeRunId !== undefined)
79
86
  return false;
80
87
  if (params.blockedByCount > 0)
@@ -92,6 +99,7 @@ export function isIssueSessionReadyForExecution(params) {
92
99
  return false;
93
100
  }
94
101
  if (deriveIssueSessionReactiveIntent({
102
+ delegatedToPatchRelay: params.delegatedToPatchRelay,
95
103
  prNumber: params.prNumber,
96
104
  prState: params.prState,
97
105
  prReviewState: params.prReviewState,
@@ -1,5 +1,6 @@
1
1
  import { formatRunTypeLabel } from "./agent-session-plan.js";
2
2
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
3
+ import { isClosedPrState } from "./pr-state.js";
3
4
  function lowerRunTypeLabel(runType) {
4
5
  return formatRunTypeLabel(runType).toLowerCase();
5
6
  }
@@ -188,23 +189,60 @@ export function buildMergePrepEscalationActivity(attempts) {
188
189
  export function summarizeIssueStateForLinear(issue) {
189
190
  switch (issue.sessionState) {
190
191
  case "waiting_input":
191
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
193
  case "running":
193
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
195
  case "idle":
196
+ if (!issue.delegatedToPatchRelay) {
197
+ break;
198
+ }
195
199
  return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
200
  case "done":
197
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
201
+ if (issue.prNumber && issue.prState === "merged")
202
+ return `PR #${issue.prNumber} has merged.`;
203
+ if (issue.prNumber && isClosedPrState(issue.prState))
204
+ return `Completed without merging PR #${issue.prNumber}.`;
205
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
198
206
  case "failed":
199
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
207
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
200
208
  }
201
209
  switch (issue.factoryState) {
210
+ case "delegated":
211
+ if (!issue.delegatedToPatchRelay) {
212
+ return "PatchRelay is queued to start work, but automation is paused.";
213
+ }
214
+ return "Queued to start work.";
215
+ case "implementing":
216
+ if (!issue.delegatedToPatchRelay) {
217
+ return "Implementation is paused because the issue is undelegated.";
218
+ }
219
+ return "Implementation in progress.";
202
220
  case "pr_open":
221
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
222
+ return `PR #${issue.prNumber} is awaiting review while PatchRelay is paused.`;
223
+ }
203
224
  return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
225
+ case "changes_requested":
226
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
227
+ return `PR #${issue.prNumber} has requested changes while PatchRelay is paused.`;
228
+ }
229
+ return issue.prNumber ? `PR #${issue.prNumber} has requested changes.` : "Requested changes received.";
230
+ case "repairing_ci":
231
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
232
+ return `PR #${issue.prNumber} has failing CI while PatchRelay is paused.`;
233
+ }
234
+ return issue.prNumber ? `PR #${issue.prNumber} has failing CI.` : "Failing CI.";
204
235
  case "awaiting_queue":
236
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
237
+ return `PR #${issue.prNumber} is approved and awaiting merge while PatchRelay is paused.`;
238
+ }
205
239
  return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
206
240
  case "done":
207
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
241
+ if (issue.prNumber && issue.prState === "merged")
242
+ return `PR #${issue.prNumber} has merged.`;
243
+ if (issue.prNumber && isClosedPrState(issue.prState))
244
+ return `Completed without merging PR #${issue.prNumber}.`;
245
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
208
246
  default:
209
247
  return undefined;
210
248
  }
@@ -29,9 +29,17 @@ export class LinearSessionSync {
29
29
  if (!linear)
30
30
  return;
31
31
  const trackedIssue = this.db.getTrackedIssue(syncedIssue.projectId, syncedIssue.linearIssueId);
32
+ const visibleIssue = trackedIssue
33
+ ? {
34
+ ...trackedIssue,
35
+ delegatedToPatchRelay: syncedIssue.delegatedToPatchRelay,
36
+ prNumber: syncedIssue.prNumber,
37
+ prUrl: syncedIssue.prUrl,
38
+ }
39
+ : syncedIssue;
32
40
  await syncActiveWorkflowState({ db: this.db, issue: syncedIssue, linear, ...(trackedIssue ? { trackedIssue } : {}), ...(options ? { options } : {}) });
33
41
  await this.agentSessions.syncSessionPlan(syncedIssue, linear, options);
34
- if (shouldSyncVisibleIssueComment(trackedIssue ?? syncedIssue, Boolean(syncedIssue.agentSessionId))) {
42
+ if (shouldSyncVisibleIssueComment(visibleIssue, Boolean(syncedIssue.agentSessionId))) {
35
43
  await syncVisibleStatusComment({
36
44
  db: this.db,
37
45
  issue: syncedIssue,