patchrelay 0.37.1 → 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 (38) 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-webhook-handler.js +70 -51
  12. package/dist/github-webhooks.js +4 -0
  13. package/dist/idle-reconciliation.js +22 -23
  14. package/dist/issue-overview-query.js +3 -0
  15. package/dist/issue-session-projector.js +1 -0
  16. package/dist/issue-session.js +8 -0
  17. package/dist/linear-session-reporting.js +30 -1
  18. package/dist/linear-session-sync.js +9 -1
  19. package/dist/linear-status-comment-sync.js +34 -1
  20. package/dist/linear-workflow-state-sync.js +2 -2
  21. package/dist/operator-retry-event.js +15 -12
  22. package/dist/paused-issue-state.js +24 -0
  23. package/dist/run-launcher.js +0 -1
  24. package/dist/run-orchestrator.js +2 -5
  25. package/dist/run-reconciler.js +10 -0
  26. package/dist/run-recovery-service.js +1 -10
  27. package/dist/service-issue-actions.js +5 -0
  28. package/dist/service-startup-recovery.js +9 -6
  29. package/dist/service.js +0 -1
  30. package/dist/tracked-issue-list-query.js +3 -1
  31. package/dist/tracked-issue-projector.js +3 -0
  32. package/dist/waiting-reason.js +10 -0
  33. package/dist/webhooks/agent-session-handler.js +9 -1
  34. package/dist/webhooks/comment-wake-handler.js +12 -0
  35. package/dist/webhooks/decision-helpers.js +44 -3
  36. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  37. package/dist/webhooks/desired-stage-recorder.js +40 -10
  38. package/package.json +1 -1
@@ -34,7 +34,6 @@ export class GitHubWebhookHandler {
34
34
  failureContextResolver;
35
35
  ciSnapshotResolver;
36
36
  fetchImpl;
37
- patchRelayAuthorLogins = new Set();
38
37
  constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
39
38
  this.config = config;
40
39
  this.db = db;
@@ -46,18 +45,6 @@ export class GitHubWebhookHandler {
46
45
  this.failureContextResolver = failureContextResolver;
47
46
  this.ciSnapshotResolver = ciSnapshotResolver;
48
47
  this.fetchImpl = fetchImpl;
49
- for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
50
- this.patchRelayAuthorLogins.add(login);
51
- }
52
- }
53
- setPatchRelayAuthorLogins(logins) {
54
- this.patchRelayAuthorLogins.clear();
55
- for (const login of logins) {
56
- const normalized = normalizeAuthorLogin(login);
57
- if (normalized) {
58
- this.patchRelayAuthorLogins.add(normalized);
59
- }
60
- }
61
48
  }
62
49
  async acceptGitHubWebhook(params) {
63
50
  // Deduplicate
@@ -130,13 +117,17 @@ export class GitHubWebhookHandler {
130
117
  this.logger.debug({ eventType: params.eventType }, "GitHub webhook: unrecognized event type or action");
131
118
  return;
132
119
  }
133
- // Route to issue via branch name
134
- 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;
135
127
  if (!issue) {
136
- 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");
137
129
  return;
138
130
  }
139
- const project = this.config.projects.find((p) => p.id === issue.projectId);
140
131
  const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
141
132
  // Update PR state on the issue
142
133
  this.db.issues.upsertIssue({
@@ -149,6 +140,7 @@ export class GitHubWebhookHandler {
149
140
  ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
150
141
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
151
142
  ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
143
+ ...(resolved.linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
152
144
  ...(event.reviewState === "changes_requested"
153
145
  ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
154
146
  : event.reviewState === "approved"
@@ -230,10 +222,57 @@ export class GitHubWebhookHandler {
230
222
  await this.handleTerminalPrEvent(freshIssue, event);
231
223
  }
232
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
+ }
233
268
  resolveFactoryStateForEvent(issue, event, project) {
234
269
  if (event.triggerEvent === "pr_closed") {
235
270
  return undefined;
236
271
  }
272
+ const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
273
+ && (event.prState === "open" || event.prNumber !== undefined)
274
+ ? "pr_open"
275
+ : issue.factoryState;
237
276
  if (event.triggerEvent === "check_failed"
238
277
  && this.isQueueEvictionFailure(issue, event, project)
239
278
  && issue.prState === "open"
@@ -241,10 +280,17 @@ export class GitHubWebhookHandler {
241
280
  && !isIssueTerminal(issue)) {
242
281
  return "repairing_queue";
243
282
  }
244
- return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
283
+ const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
245
284
  prReviewState: issue.prReviewState,
246
285
  activeRunId: issue.activeRunId,
247
286
  });
287
+ if (resolved !== undefined) {
288
+ return resolved;
289
+ }
290
+ if (effectiveCurrentState !== issue.factoryState) {
291
+ return effectiveCurrentState;
292
+ }
293
+ return undefined;
248
294
  }
249
295
  async updateCiSnapshot(issue, event, project) {
250
296
  if (event.triggerEvent === "pr_merged") {
@@ -327,15 +373,15 @@ export class GitHubWebhookHandler {
327
373
  // merge_group_failed after pr_merged) must not resurrect done issues.
328
374
  if (isIssueTerminal(issue))
329
375
  return;
330
- if (!this.isPatchRelayOwnedPr(issue)) {
376
+ if (!issue.delegatedToPatchRelay) {
331
377
  this.feed?.publish({
332
378
  level: "info",
333
379
  kind: "github",
334
380
  issueKey: issue.issueKey,
335
381
  projectId: issue.projectId,
336
382
  stage: issue.factoryState,
337
- status: "ignored_non_patchrelay_pr",
338
- summary: `Ignored ${event.triggerEvent} on non-PatchRelay-owned PR`,
383
+ status: "ignored_undelegated",
384
+ summary: `Ignored ${event.triggerEvent} because the issue is undelegated`,
339
385
  });
340
386
  return;
341
387
  }
@@ -372,7 +418,6 @@ export class GitHubWebhookHandler {
372
418
  }),
373
419
  dedupeKey: failureContext.failureSignature,
374
420
  });
375
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
376
421
  const queuedRunType = hadPendingWake
377
422
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
378
423
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -430,7 +475,6 @@ export class GitHubWebhookHandler {
430
475
  }),
431
476
  dedupeKey: failureContext.failureSignature,
432
477
  });
433
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
434
478
  const queuedRunType = hadPendingWake
435
479
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
436
480
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -476,7 +520,6 @@ export class GitHubWebhookHandler {
476
520
  event.reviewerName ?? "unknown-reviewer",
477
521
  ].join("::"),
478
522
  });
479
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
480
523
  const queuedRunType = hadPendingWake
481
524
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
482
525
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -554,7 +597,6 @@ export class GitHubWebhookHandler {
554
597
  eventType: "delegated",
555
598
  dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
556
599
  });
557
- this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
558
600
  if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
559
601
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
560
602
  }
@@ -709,7 +751,7 @@ export class GitHubWebhookHandler {
709
751
  if (!signature)
710
752
  return false;
711
753
  const pendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
712
- if (pendingWake?.runType === runType) {
754
+ if (pendingWake?.runType === runType && pendingWake.eventIds.length > 0) {
713
755
  const existing = pendingWake.context;
714
756
  if (existing?.failureSignature === signature
715
757
  && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
@@ -945,8 +987,6 @@ export class GitHubWebhookHandler {
945
987
  const issue = this.db.issues.getIssueByPrNumber(prNumber);
946
988
  if (!issue)
947
989
  return;
948
- if (!this.isPatchRelayOwnedPr(issue))
949
- return;
950
990
  this.feed?.publish({
951
991
  level: "info",
952
992
  kind: "comment",
@@ -1009,30 +1049,9 @@ export class GitHubWebhookHandler {
1009
1049
  this.enqueueIssue(projectId, issueId);
1010
1050
  return wake.runType;
1011
1051
  }
1012
- isPatchRelayOwnedPr(issue) {
1013
- const author = normalizeAuthorLogin(issue.prAuthorLogin);
1014
- if (author) {
1015
- if (this.patchRelayAuthorLogins.size > 0) {
1016
- return this.patchRelayAuthorLogins.has(author);
1017
- }
1018
- return author.includes("patchrelay");
1019
- }
1020
- // Transitional fallback for rows written before author tracking existed.
1021
- return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
1022
- }
1023
- }
1024
- function normalizeAuthorLogin(login) {
1025
- const normalized = login?.trim().toLowerCase();
1026
- return normalized ? normalized : undefined;
1027
1052
  }
1028
- function resolvePatchRelayAuthorLoginsFromEnv() {
1029
- return [
1030
- process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
1031
- process.env.PATCHRELAY_GITHUB_BOT_NAME,
1032
- ]
1033
- .flatMap((value) => (value ?? "").split(","))
1034
- .map((value) => normalizeAuthorLogin(value))
1035
- .filter((value) => Boolean(value));
1053
+ function escapeRegExp(value) {
1054
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1036
1055
  }
1037
1056
  function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
1038
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",
@@ -1,4 +1,3 @@
1
- import {} from "./factory-state.js";
2
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
2
  import { parseGitHubFailureContext } from "./github-failure-context.js";
4
3
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
@@ -90,15 +89,6 @@ function hasFailureProvenance(issue) {
90
89
  || issue.lastAttemptedFailureHeadSha
91
90
  || issue.lastAttemptedFailureSignature);
92
91
  }
93
- export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
94
- if (pendingRunType)
95
- return "patchrelay";
96
- if (newState === "awaiting_queue")
97
- return "patchrelay";
98
- if (newState === "repairing_ci" || newState === "repairing_queue")
99
- return "patchrelay";
100
- return undefined;
101
- }
102
92
  export class IdleIssueReconciler {
103
93
  db;
104
94
  config;
@@ -154,6 +144,8 @@ export class IdleIssueReconciler {
154
144
  await this.reconcileFromGitHub(issue);
155
145
  }
156
146
  for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
147
+ if (!issue.delegatedToPatchRelay)
148
+ continue;
157
149
  const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
158
150
  if (unresolved === 0) {
159
151
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
@@ -207,10 +199,6 @@ export class IdleIssueReconciler {
207
199
  }
208
200
  : {}),
209
201
  });
210
- const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
211
- if (branchOwner) {
212
- this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
213
- }
214
202
  if (options?.pendingRunType) {
215
203
  this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
216
204
  }
@@ -255,6 +243,9 @@ export class IdleIssueReconciler {
255
243
  });
256
244
  }
257
245
  async routeFailedIssue(issue) {
246
+ if (!issue.delegatedToPatchRelay) {
247
+ return;
248
+ }
258
249
  issue = await this.refreshMissingFailureProvenance(issue);
259
250
  issue = await this.reclassifyStaleBranchFailure(issue);
260
251
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
@@ -457,11 +448,17 @@ export class IdleIssueReconciler {
457
448
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
458
449
  return;
459
450
  }
460
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished work, re-delegating for implementation");
461
- this.advanceIdleIssue(issue, "delegated", {
462
- pendingRunType: "implementation",
463
- clearFailureProvenance: true,
464
- });
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
+ }
465
462
  return;
466
463
  }
467
464
  const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
@@ -481,7 +478,8 @@ export class IdleIssueReconciler {
481
478
  return;
482
479
  }
483
480
  }
484
- if (isReviewDecisionReviewRequired(pr.reviewDecision)
481
+ if (issue.delegatedToPatchRelay
482
+ && isReviewDecisionReviewRequired(pr.reviewDecision)
485
483
  && gateCheckStatus === "success"
486
484
  && hasCompletedReviewQuillVerdict(pr.statusCheckRollup)) {
487
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");
@@ -509,7 +507,8 @@ export class IdleIssueReconciler {
509
507
  mergeConflictDetected,
510
508
  downstreamOwned,
511
509
  });
512
- if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
510
+ if (issue.delegatedToPatchRelay
511
+ && (issue.factoryState === "escalated" || issue.factoryState === "failed")
513
512
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
514
513
  if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
515
514
  this.logger.debug({
@@ -538,7 +537,7 @@ export class IdleIssueReconciler {
538
537
  });
539
538
  return;
540
539
  }
541
- if (reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
540
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
542
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");
543
542
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
544
543
  pendingRunType: reactiveIntent.runType,
@@ -555,7 +554,7 @@ export class IdleIssueReconciler {
555
554
  });
556
555
  return;
557
556
  }
558
- if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
557
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
559
558
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
560
559
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
561
560
  pendingRunType: reactiveIntent.runType,
@@ -138,6 +138,7 @@ 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",
@@ -154,6 +155,7 @@ export class IssueOverviewQuery {
154
155
  id: issueRecord?.id ?? session.id,
155
156
  projectId: session.projectId,
156
157
  linearIssueId: session.linearIssueId,
158
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay ?? true,
157
159
  ...(session.issueKey ? { issueKey: session.issueKey } : {}),
158
160
  ...(issueRecord?.title ? { title: issueRecord.title } : {}),
159
161
  ...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
@@ -169,6 +171,7 @@ export class IssueOverviewQuery {
169
171
  readyForExecution: isIssueSessionReadyForExecution({
170
172
  sessionState: session.sessionState,
171
173
  factoryState: issueRecord?.factoryState ?? "delegated",
174
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
172
175
  ...(activeRun ? { activeRunId: activeRun.id } : {}),
173
176
  blockedByCount: unresolvedBlockedBy.length,
174
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,
@@ -193,7 +193,10 @@ export function summarizeIssueStateForLinear(issue) {
193
193
  case "running":
194
194
  return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
195
195
  case "idle":
196
- return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
+ if (!issue.delegatedToPatchRelay) {
197
+ break;
198
+ }
199
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
197
200
  case "done":
198
201
  if (issue.prNumber && issue.prState === "merged")
199
202
  return `PR #${issue.prNumber} has merged.`;
@@ -204,9 +207,35 @@ export function summarizeIssueStateForLinear(issue) {
204
207
  return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
205
208
  }
206
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.";
207
220
  case "pr_open":
221
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
222
+ return `PR #${issue.prNumber} is awaiting review while PatchRelay is paused.`;
223
+ }
208
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.";
209
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
+ }
210
239
  return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
211
240
  case "done":
212
241
  if (issue.prNumber && issue.prState === "merged")
@@ -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,
@@ -2,6 +2,7 @@ import { extractCompletionCheck } from "./completion-check.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
4
4
  import { isClosedPrState } from "./pr-state.js";
5
+ import { isUndelegatedPausedIssue } from "./paused-issue-state.js";
5
6
  export async function syncVisibleStatusComment(params) {
6
7
  const { db, issue, linear, logger, trackedIssue, options } = params;
7
8
  try {
@@ -32,6 +33,9 @@ export function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
32
33
  || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
33
34
  return true;
34
35
  }
36
+ if (isUndelegatedPausedIssue(issue)) {
37
+ return true;
38
+ }
35
39
  if ((issue.sessionState === "done" || issue.factoryState === "done")
36
40
  && ((issue.prNumber === undefined && !issue.prUrl)
37
41
  || isClosedPrState(issue.prState))) {
@@ -47,6 +51,7 @@ function renderStatusComment(db, issue, trackedIssue, options) {
47
51
  ? (options?.activeRunType ?? activeRun?.runType)
48
52
  : undefined;
49
53
  const waitingReason = trackedIssue?.waitingReason ?? derivePatchRelayWaitingReason({
54
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
50
55
  ...(activeRunType ? { activeRunType } : {}),
51
56
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
52
57
  factoryState: issue.factoryState,
@@ -62,7 +67,15 @@ function renderStatusComment(db, issue, trackedIssue, options) {
62
67
  const lines = [
63
68
  "## PatchRelay status",
64
69
  "",
65
- statusHeadline(trackedIssue ?? issue, activeRunType),
70
+ statusHeadline(trackedIssue
71
+ ? {
72
+ ...trackedIssue,
73
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
74
+ prNumber: issue.prNumber,
75
+ prReviewState: issue.prReviewState,
76
+ prCheckStatus: issue.prCheckStatus,
77
+ }
78
+ : issue, activeRunType),
66
79
  ];
67
80
  const statusNote = trackedIssue?.statusNote ?? deriveIssueStatusNote({ issue, latestRun, latestEvent, waitingReason });
68
81
  if (waitingReason) {
@@ -124,6 +137,26 @@ function statusHeadline(issue, activeRunType) {
124
137
  default:
125
138
  break;
126
139
  }
140
+ if (!issue.delegatedToPatchRelay && issue.prNumber !== undefined) {
141
+ if (issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved") {
142
+ return `PR #${issue.prNumber} is awaiting downstream merge while PatchRelay is paused`;
143
+ }
144
+ if (issue.factoryState === "changes_requested" || issue.prReviewState === "changes_requested") {
145
+ return `PR #${issue.prNumber} has requested changes while PatchRelay is paused`;
146
+ }
147
+ if (issue.factoryState === "repairing_ci" || issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
148
+ return `PR #${issue.prNumber} has failing CI while PatchRelay is paused`;
149
+ }
150
+ return `PR #${issue.prNumber} is awaiting review while PatchRelay is paused`;
151
+ }
152
+ if (!issue.delegatedToPatchRelay) {
153
+ if (issue.factoryState === "implementing") {
154
+ return "Implementation is paused because the issue is undelegated";
155
+ }
156
+ if (issue.factoryState === "delegated") {
157
+ return "Queued to start work while PatchRelay is paused";
158
+ }
159
+ }
127
160
  switch (issue.factoryState) {
128
161
  case "delegated":
129
162
  return "Queued to start work";
@@ -53,14 +53,14 @@ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIss
53
53
  || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
54
54
  return resolvePreferredHumanNeededLinearState(liveIssue);
55
55
  }
56
- const activelyWorking = issue.activeRunId !== undefined
56
+ const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
57
57
  || options?.activeRunType !== undefined
58
58
  || trackedIssue?.sessionState === "running"
59
59
  || issue.factoryState === "delegated"
60
60
  || issue.factoryState === "implementing"
61
61
  || issue.factoryState === "changes_requested"
62
62
  || issue.factoryState === "repairing_ci"
63
- || issue.factoryState === "repairing_queue";
63
+ || issue.factoryState === "repairing_queue");
64
64
  if (activelyWorking) {
65
65
  return resolvePreferredImplementingLinearState(liveIssue);
66
66
  }
@@ -9,7 +9,7 @@ function parseObjectJson(value) {
9
9
  return undefined;
10
10
  }
11
11
  }
12
- export function buildOperatorRetryEvent(issue, runType) {
12
+ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry") {
13
13
  if (runType === "queue_repair") {
14
14
  const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
15
15
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
@@ -18,9 +18,9 @@ export function buildOperatorRetryEvent(issue, runType) {
18
18
  eventJson: JSON.stringify({
19
19
  ...(queueIncident ?? {}),
20
20
  ...(failureContext ?? {}),
21
- source: "operator_retry",
21
+ source,
22
22
  }),
23
- dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
23
+ dedupeKey: `${source}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
24
24
  };
25
25
  }
26
26
  if (runType === "ci_repair") {
@@ -29,9 +29,9 @@ export function buildOperatorRetryEvent(issue, runType) {
29
29
  eventType: "settled_red_ci",
30
30
  eventJson: JSON.stringify({
31
31
  ...(failureContext ?? {}),
32
- source: "operator_retry",
32
+ source,
33
33
  }),
34
- dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
34
+ dedupeKey: `${source}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
35
35
  };
36
36
  }
37
37
  if (runType === "review_fix" || runType === "branch_upkeep") {
@@ -39,20 +39,23 @@ export function buildOperatorRetryEvent(issue, runType) {
39
39
  eventType: "review_changes_requested",
40
40
  eventJson: JSON.stringify({
41
41
  reviewBody: runType === "branch_upkeep"
42
- ? "Operator requested retry of branch upkeep after requested changes."
43
- : "Operator requested retry of review-fix work.",
42
+ ? `${humanizeSource(source)} requested retry of branch upkeep after requested changes.`
43
+ : `${humanizeSource(source)} requested retry of review-fix work.`,
44
44
  ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
45
- source: "operator_retry",
45
+ source,
46
46
  }),
47
- dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
47
+ dedupeKey: `${source}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
48
48
  };
49
49
  }
50
50
  return {
51
51
  eventType: "delegated",
52
52
  eventJson: JSON.stringify({
53
- promptContext: "Operator requested retry of PatchRelay work.",
54
- source: "operator_retry",
53
+ promptContext: `${humanizeSource(source)} requested PatchRelay work resume.`,
54
+ source,
55
55
  }),
56
- dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
56
+ dedupeKey: `${source}:implementation:${issue.linearIssueId}`,
57
57
  };
58
58
  }
59
+ function humanizeSource(source) {
60
+ return source.replaceAll("_", " ");
61
+ }