patchrelay 0.36.7 → 0.36.9

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.
@@ -60,11 +60,11 @@ export class GitHubWebhookHandler {
60
60
  }
61
61
  async acceptGitHubWebhook(params) {
62
62
  // Deduplicate
63
- if (this.db.isWebhookDuplicate(params.deliveryId)) {
63
+ if (this.db.webhookEvents.isWebhookDuplicate(params.deliveryId)) {
64
64
  return { status: 200, body: { ok: true, duplicate: true } };
65
65
  }
66
66
  // Store the event
67
- const stored = this.db.insertWebhookEvent(params.deliveryId, new Date().toISOString());
67
+ const stored = this.db.webhookEvents.insertWebhookEvent(params.deliveryId, new Date().toISOString());
68
68
  // Parse payload
69
69
  const payload = safeJsonParse(params.rawBody.toString("utf8"));
70
70
  if (!payload) {
@@ -130,14 +130,14 @@ export class GitHubWebhookHandler {
130
130
  return;
131
131
  }
132
132
  // Route to issue via branch name
133
- const issue = this.db.getIssueByBranch(event.branchName);
133
+ const issue = this.db.issues.getIssueByBranch(event.branchName);
134
134
  if (!issue) {
135
135
  this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
136
136
  return;
137
137
  }
138
138
  const project = this.config.projects.find((p) => p.id === issue.projectId);
139
139
  // Update PR state on the issue
140
- this.db.upsertIssue({
140
+ this.db.issues.upsertIssue({
141
141
  projectId: issue.projectId,
142
142
  linearIssueId: issue.linearIssueId,
143
143
  ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
@@ -158,28 +158,28 @@ export class GitHubWebhookHandler {
158
158
  const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
159
159
  if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
160
160
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
161
- const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
161
+ const afterMetadata = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
162
162
  const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
163
163
  // Only transition and notify when the state actually changes.
164
164
  // Multiple check_suite events can arrive for the same outcome.
165
165
  if (newState && newState !== afterMetadata.factoryState) {
166
- this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
166
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
167
167
  projectId: issue.projectId,
168
168
  linearIssueId: issue.linearIssueId,
169
169
  factoryState: newState,
170
170
  });
171
171
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
172
- const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
172
+ const transitionedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
173
173
  void this.emitLinearActivity(transitionedIssue, newState, event);
174
174
  void this.syncLinearSession(transitionedIssue);
175
175
  }
176
176
  }
177
177
  // Re-read issue after all upserts so reactive run logic sees current state
178
- const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
178
+ const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
179
179
  // Reset repair counters on new push — but only when no repair run is active,
180
180
  // since Codex pushes during repair and resetting mid-run would bypass budgets.
181
181
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
182
- this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
182
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
183
183
  projectId: issue.projectId,
184
184
  linearIssueId: issue.linearIssueId,
185
185
  ciRepairAttempts: 0,
@@ -240,7 +240,7 @@ export class GitHubWebhookHandler {
240
240
  }
241
241
  async updateCiSnapshot(issue, event, project) {
242
242
  if (event.triggerEvent === "pr_merged") {
243
- this.db.upsertIssue({
243
+ this.db.issues.upsertIssue({
244
244
  projectId: issue.projectId,
245
245
  linearIssueId: issue.linearIssueId,
246
246
  lastGitHubCiSnapshotHeadSha: null,
@@ -252,7 +252,7 @@ export class GitHubWebhookHandler {
252
252
  return;
253
253
  }
254
254
  if (event.triggerEvent === "pr_synchronize") {
255
- this.db.upsertIssue({
255
+ this.db.issues.upsertIssue({
256
256
  projectId: issue.projectId,
257
257
  linearIssueId: issue.linearIssueId,
258
258
  lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
@@ -279,7 +279,7 @@ export class GitHubWebhookHandler {
279
279
  gateCheckNames: this.getGateCheckNames(project),
280
280
  });
281
281
  if (!snapshot) {
282
- this.db.upsertIssue({
282
+ this.db.issues.upsertIssue({
283
283
  projectId: issue.projectId,
284
284
  linearIssueId: issue.linearIssueId,
285
285
  lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
@@ -300,7 +300,7 @@ export class GitHubWebhookHandler {
300
300
  });
301
301
  return;
302
302
  }
303
- this.db.upsertIssue({
303
+ this.db.issues.upsertIssue({
304
304
  projectId: issue.projectId,
305
305
  linearIssueId: issue.linearIssueId,
306
306
  prCheckStatus: snapshot.gateCheckStatus,
@@ -340,8 +340,8 @@ export class GitHubWebhookHandler {
340
340
  if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
341
341
  return;
342
342
  }
343
- const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
344
- this.db.upsertIssue({
343
+ const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
344
+ this.db.issues.upsertIssue({
345
345
  projectId: issue.projectId,
346
346
  linearIssueId: issue.linearIssueId,
347
347
  lastGitHubFailureSource: "queue_eviction",
@@ -354,7 +354,7 @@ export class GitHubWebhookHandler {
354
354
  lastQueueSignalAt: new Date().toISOString(),
355
355
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
356
356
  });
357
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
357
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
358
358
  projectId: issue.projectId,
359
359
  linearIssueId: issue.linearIssueId,
360
360
  eventType: "merge_steward_incident",
@@ -364,7 +364,7 @@ export class GitHubWebhookHandler {
364
364
  }),
365
365
  dedupeKey: failureContext.failureSignature,
366
366
  });
367
- this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
367
+ this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
368
368
  const queuedRunType = hadPendingWake
369
369
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
370
370
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -397,9 +397,9 @@ export class GitHubWebhookHandler {
397
397
  if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
398
398
  return;
399
399
  }
400
- const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
400
+ const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
401
401
  const snapshot = this.getRelevantCiSnapshot(issue, event);
402
- this.db.upsertIssue({
402
+ this.db.issues.upsertIssue({
403
403
  projectId: issue.projectId,
404
404
  linearIssueId: issue.linearIssueId,
405
405
  lastGitHubFailureSource: "branch_ci",
@@ -411,7 +411,7 @@ export class GitHubWebhookHandler {
411
411
  lastGitHubFailureAt: new Date().toISOString(),
412
412
  lastQueueIncidentJson: null,
413
413
  });
414
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
414
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
415
415
  projectId: issue.projectId,
416
416
  linearIssueId: issue.linearIssueId,
417
417
  eventType: "settled_red_ci",
@@ -422,7 +422,7 @@ export class GitHubWebhookHandler {
422
422
  }),
423
423
  dedupeKey: failureContext.failureSignature,
424
424
  });
425
- this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
425
+ this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
426
426
  const queuedRunType = hadPendingWake
427
427
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
428
428
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -440,7 +440,7 @@ export class GitHubWebhookHandler {
440
440
  }
441
441
  }
442
442
  if (event.triggerEvent === "review_changes_requested") {
443
- const hadPendingWake = this.db.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
443
+ const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
444
444
  const reviewComments = await this.fetchReviewCommentsForEvent(event).catch((error) => {
445
445
  this.logger.warn({
446
446
  issueKey: issue.issueKey,
@@ -450,7 +450,7 @@ export class GitHubWebhookHandler {
450
450
  }, "Failed to fetch inline review comments for requested-changes event");
451
451
  return undefined;
452
452
  });
453
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
453
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
454
454
  projectId: issue.projectId,
455
455
  linearIssueId: issue.linearIssueId,
456
456
  eventType: "review_changes_requested",
@@ -468,7 +468,7 @@ export class GitHubWebhookHandler {
468
468
  event.reviewerName ?? "unknown-reviewer",
469
469
  ].join("::"),
470
470
  });
471
- this.db.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
471
+ this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
472
472
  const queuedRunType = hadPendingWake
473
473
  ? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
474
474
  : this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
@@ -489,14 +489,14 @@ export class GitHubWebhookHandler {
489
489
  }
490
490
  async handleTerminalPrEvent(issue, event) {
491
491
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
492
- this.db.appendIssueSessionEvent({
492
+ this.db.issueSessions.appendIssueSessionEvent({
493
493
  projectId: issue.projectId,
494
494
  linearIssueId: issue.linearIssueId,
495
495
  eventType,
496
496
  dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
497
497
  });
498
- this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
499
- const run = issue.activeRunId ? this.db.getRun(issue.activeRunId) : undefined;
498
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
499
+ const run = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
500
500
  if (run?.threadId && run.turnId) {
501
501
  try {
502
502
  await this.codex.steerTurn({
@@ -513,29 +513,29 @@ export class GitHubWebhookHandler {
513
513
  }
514
514
  const commitTerminalUpdate = () => {
515
515
  if (run) {
516
- this.db.finishRun(run.id, {
516
+ this.db.runs.finishRun(run.id, {
517
517
  status: "released",
518
518
  failureReason: event.triggerEvent === "pr_merged"
519
519
  ? "Pull request merged during active run"
520
520
  : "Pull request closed during active run",
521
521
  });
522
522
  }
523
- this.db.upsertIssue({
523
+ this.db.issues.upsertIssue({
524
524
  projectId: issue.projectId,
525
525
  linearIssueId: issue.linearIssueId,
526
526
  activeRunId: null,
527
527
  factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
528
528
  });
529
529
  };
530
- const activeLease = this.db.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
530
+ const activeLease = this.db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
531
531
  if (activeLease) {
532
- this.db.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
532
+ this.db.issueSessions.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
533
533
  }
534
534
  else {
535
535
  this.db.transaction(commitTerminalUpdate);
536
536
  }
537
- this.db.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
538
- const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
537
+ this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
538
+ const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
539
539
  if (event.triggerEvent === "pr_merged") {
540
540
  await this.completeLinearIssueAfterMerge(updatedIssue);
541
541
  }
@@ -554,7 +554,7 @@ export class GitHubWebhookHandler {
554
554
  }
555
555
  const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
556
556
  if (normalizedCurrent === targetState.trim().toLowerCase()) {
557
- this.db.upsertIssue({
557
+ this.db.issues.upsertIssue({
558
558
  projectId: issue.projectId,
559
559
  linearIssueId: issue.linearIssueId,
560
560
  ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
@@ -563,7 +563,7 @@ export class GitHubWebhookHandler {
563
563
  return;
564
564
  }
565
565
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
566
- this.db.upsertIssue({
566
+ this.db.issues.upsertIssue({
567
567
  projectId: issue.projectId,
568
568
  linearIssueId: issue.linearIssueId,
569
569
  ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
@@ -587,7 +587,7 @@ export class GitHubWebhookHandler {
587
587
  const failureContext = source === "queue_eviction"
588
588
  ? this.buildQueueFailureContext(issue, event)
589
589
  : await this.resolveBranchFailureContext(issue, event, project);
590
- this.db.upsertIssue({
590
+ this.db.issues.upsertIssue({
591
591
  projectId: issue.projectId,
592
592
  linearIssueId: issue.linearIssueId,
593
593
  lastGitHubFailureSource: source,
@@ -614,7 +614,7 @@ export class GitHubWebhookHandler {
614
614
  if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
615
615
  return;
616
616
  }
617
- this.db.upsertIssue({
617
+ this.db.issues.upsertIssue({
618
618
  projectId: issue.projectId,
619
619
  linearIssueId: issue.linearIssueId,
620
620
  lastGitHubFailureSource: null,
@@ -685,7 +685,7 @@ export class GitHubWebhookHandler {
685
685
  : typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
686
686
  if (!signature)
687
687
  return false;
688
- const pendingWake = this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
688
+ const pendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
689
689
  if (pendingWake?.runType === runType) {
690
690
  const existing = pendingWake.context;
691
691
  if (existing?.failureSignature === signature
@@ -763,7 +763,7 @@ export class GitHubWebhookHandler {
763
763
  return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
764
764
  }
765
765
  getRelevantCiSnapshot(issue, event) {
766
- const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
766
+ const snapshot = this.db.issues.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
767
767
  if (!snapshot)
768
768
  return undefined;
769
769
  if (snapshot.headSha !== event.headSha)
@@ -904,7 +904,7 @@ export class GitHubWebhookHandler {
904
904
  const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
905
905
  if (!prNumber)
906
906
  return;
907
- const issue = this.db.getIssueByPrNumber(prNumber);
907
+ const issue = this.db.issues.getIssueByPrNumber(prNumber);
908
908
  if (!issue)
909
909
  return;
910
910
  if (!this.isPatchRelayOwnedPr(issue))
@@ -920,7 +920,7 @@ export class GitHubWebhookHandler {
920
920
  detail: body.slice(0, 200),
921
921
  });
922
922
  if (issue.activeRunId) {
923
- const run = this.db.getRun(issue.activeRunId);
923
+ const run = this.db.runs.getRunById(issue.activeRunId);
924
924
  if (run?.threadId && run.turnId) {
925
925
  try {
926
926
  await this.codex.steerTurn({
@@ -937,7 +937,7 @@ export class GitHubWebhookHandler {
937
937
  }
938
938
  }
939
939
  }
940
- this.db.appendIssueSessionEvent({
940
+ this.db.issueSessions.appendIssueSessionEvent({
941
941
  projectId: issue.projectId,
942
942
  linearIssueId: issue.linearIssueId,
943
943
  eventType: "followup_comment",
@@ -961,10 +961,10 @@ export class GitHubWebhookHandler {
961
961
  return response.statusText || `GitHub API responded with ${response.status}`;
962
962
  }
963
963
  peekPendingSessionWakeRunType(projectId, issueId) {
964
- return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
964
+ return this.db.issueSessions.peekIssueSessionWake(projectId, issueId)?.runType;
965
965
  }
966
966
  enqueuePendingSessionWake(projectId, issueId) {
967
- const wake = this.db.peekIssueSessionWake(projectId, issueId);
967
+ const wake = this.db.issueSessions.peekIssueSessionWake(projectId, issueId);
968
968
  if (!wake) {
969
969
  return undefined;
970
970
  }
@@ -110,7 +110,7 @@ export class IdleIssueReconciler {
110
110
  this.feed = feed;
111
111
  }
112
112
  async reconcile() {
113
- for (const issue of this.db.listIdleNonTerminalIssues()) {
113
+ for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
114
114
  if (issue.prState === "merged") {
115
115
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
116
116
  continue;
@@ -145,21 +145,21 @@ export class IdleIssueReconciler {
145
145
  await this.reconcileFromGitHub(issue);
146
146
  }
147
147
  }
148
- for (const issue of this.db.listIssues()) {
148
+ for (const issue of this.db.issues.listIssues()) {
149
149
  if (!this.shouldProbeTerminalIssueFromGitHub(issue))
150
150
  continue;
151
151
  await this.reconcileFromGitHub(issue);
152
152
  }
153
- for (const issue of this.db.listBlockedDelegatedIssues()) {
154
- const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
153
+ for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
154
+ const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
155
155
  if (unresolved === 0) {
156
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
156
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
157
157
  projectId: issue.projectId,
158
158
  linearIssueId: issue.linearIssueId,
159
159
  eventType: "delegated",
160
160
  dedupeKey: `delegated:${issue.linearIssueId}`,
161
161
  });
162
- if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
162
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
163
163
  this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
164
164
  }
165
165
  }
@@ -179,7 +179,7 @@ export class IdleIssueReconciler {
179
179
  return;
180
180
  }
181
181
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
182
- this.db.upsertIssue({
182
+ this.db.issues.upsertIssue({
183
183
  projectId: issue.projectId,
184
184
  linearIssueId: issue.linearIssueId,
185
185
  factoryState: newState,
@@ -206,7 +206,7 @@ export class IdleIssueReconciler {
206
206
  });
207
207
  const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
208
208
  if (branchOwner) {
209
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
209
+ this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
210
210
  }
211
211
  if (options?.pendingRunType) {
212
212
  this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
@@ -220,7 +220,7 @@ export class IdleIssueReconciler {
220
220
  status: "reconciled",
221
221
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
222
222
  });
223
- if (options?.pendingRunType && this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
223
+ if (options?.pendingRunType && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
224
224
  this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
225
225
  }
226
226
  }
@@ -243,7 +243,7 @@ export class IdleIssueReconciler {
243
243
  eventType = "delegated";
244
244
  dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
245
245
  }
246
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
246
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
247
247
  projectId: issue.projectId,
248
248
  linearIssueId: issue.linearIssueId,
249
249
  eventType,
@@ -254,7 +254,7 @@ export class IdleIssueReconciler {
254
254
  async routeFailedIssue(issue) {
255
255
  issue = await this.refreshMissingFailureProvenance(issue);
256
256
  issue = await this.reclassifyStaleBranchFailure(issue);
257
- const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
257
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
258
258
  const ignoreDuplicateAttempt = latestRun?.status === "failed"
259
259
  && latestRun.failureReason === "Codex turn was interrupted";
260
260
  const reactiveIntent = deriveIssueSessionReactiveIntent({
@@ -309,7 +309,7 @@ export class IdleIssueReconciler {
309
309
  ?? (inferred === "queue_eviction" && failureHeadSha && checkName
310
310
  ? ["queue_eviction", failureHeadSha, checkName].join("::")
311
311
  : null);
312
- this.db.upsertIssue({
312
+ this.db.issues.upsertIssue({
313
313
  projectId: issue.projectId,
314
314
  linearIssueId: issue.linearIssueId,
315
315
  lastGitHubFailureSource: inferred,
@@ -317,7 +317,7 @@ export class IdleIssueReconciler {
317
317
  ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
318
318
  ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
319
319
  });
320
- const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
320
+ const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
321
321
  if (!refreshed)
322
322
  return issue;
323
323
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
@@ -337,7 +337,7 @@ export class IdleIssueReconciler {
337
337
  const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
338
338
  const failureSignature = issue.lastGitHubFailureSignature
339
339
  ?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
340
- this.db.upsertIssue({
340
+ this.db.issues.upsertIssue({
341
341
  projectId: issue.projectId,
342
342
  linearIssueId: issue.linearIssueId,
343
343
  lastGitHubFailureSource: "queue_eviction",
@@ -345,7 +345,7 @@ export class IdleIssueReconciler {
345
345
  ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
346
346
  ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
347
347
  });
348
- const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
348
+ const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
349
349
  if (!refreshed)
350
350
  return issue;
351
351
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
@@ -410,7 +410,7 @@ export class IdleIssueReconciler {
410
410
  const previousHeadSha = issue.prHeadSha;
411
411
  const gateCheckNames = getGateCheckNames(project);
412
412
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
413
- this.db.upsertIssue({
413
+ this.db.issues.upsertIssue({
414
414
  projectId: issue.projectId,
415
415
  linearIssueId: issue.linearIssueId,
416
416
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
@@ -433,13 +433,13 @@ export class IdleIssueReconciler {
433
433
  : {}),
434
434
  });
435
435
  if (pr.state === "MERGED") {
436
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
436
+ this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
437
437
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
438
438
  return;
439
439
  }
440
440
  if (pr.state === "CLOSED") {
441
441
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed, re-delegating for implementation");
442
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
442
+ this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
443
443
  this.advanceIdleIssue(issue, "delegated", {
444
444
  pendingRunType: "implementation",
445
445
  clearFailureProvenance: true,
@@ -481,7 +481,7 @@ export class IdleIssueReconciler {
481
481
  }
482
482
  const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" || pr.reviewDecision === "APPROVED";
483
483
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
484
- const refreshedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
484
+ const refreshedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
485
485
  const reactiveIntent = deriveIssueSessionReactiveIntent({
486
486
  prNumber: refreshedIssue.prNumber,
487
487
  prState: refreshedIssue.prState,
@@ -549,7 +549,7 @@ export class IdleIssueReconciler {
549
549
  return;
550
550
  }
551
551
  if (isReviewDecisionApproved(pr.reviewDecision)) {
552
- this.db.upsertIssue({
552
+ this.db.issues.upsertIssue({
553
553
  projectId: issue.projectId,
554
554
  linearIssueId: issue.linearIssueId,
555
555
  prReviewState: "approved",
@@ -0,0 +1,176 @@
1
+ import { ACTIVE_RUN_STATES } from "./factory-state.js";
2
+ import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
+ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
4
+ function isRequestedChangesRunType(runType) {
5
+ return runType === "review_fix" || runType === "branch_upkeep";
6
+ }
7
+ function resolveRetryRunType(runType, context) {
8
+ if (runType === "branch_upkeep") {
9
+ return "branch_upkeep";
10
+ }
11
+ return context?.reviewFixMode === "branch_upkeep" || context?.branchUpkeepRequired === true
12
+ ? "branch_upkeep"
13
+ : "review_fix";
14
+ }
15
+ function resolvePostRunState(issue) {
16
+ if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
17
+ if (issue.prState === "merged")
18
+ return "done";
19
+ if (issue.prReviewState === "approved")
20
+ return "awaiting_queue";
21
+ return "pr_open";
22
+ }
23
+ return undefined;
24
+ }
25
+ export function resolveRecoverablePostRunState(issue) {
26
+ if (!issue.prNumber) {
27
+ return resolvePostRunState(issue);
28
+ }
29
+ if (issue.prState === "merged")
30
+ return "done";
31
+ if (issue.prState === "open") {
32
+ const reactiveIntent = deriveIssueSessionReactiveIntent({
33
+ prNumber: issue.prNumber,
34
+ prState: issue.prState,
35
+ prReviewState: issue.prReviewState,
36
+ prCheckStatus: issue.prCheckStatus,
37
+ latestFailureSource: issue.lastGitHubFailureSource,
38
+ });
39
+ if (reactiveIntent)
40
+ return reactiveIntent.compatibilityFactoryState;
41
+ if (issue.prReviewState === "approved")
42
+ return "awaiting_queue";
43
+ return "pr_open";
44
+ }
45
+ return resolvePostRunState(issue);
46
+ }
47
+ export class InterruptedRunRecovery {
48
+ db;
49
+ logger;
50
+ linearSync;
51
+ withHeldLease;
52
+ releaseLease;
53
+ failRunAndClear;
54
+ restoreIdleWorktree;
55
+ completionPolicy;
56
+ enqueueIssue;
57
+ feed;
58
+ constructor(db, logger, linearSync, withHeldLease, releaseLease, failRunAndClear, restoreIdleWorktree, completionPolicy, enqueueIssue, feed) {
59
+ this.db = db;
60
+ this.logger = logger;
61
+ this.linearSync = linearSync;
62
+ this.withHeldLease = withHeldLease;
63
+ this.releaseLease = releaseLease;
64
+ this.failRunAndClear = failRunAndClear;
65
+ this.restoreIdleWorktree = restoreIdleWorktree;
66
+ this.completionPolicy = completionPolicy;
67
+ this.enqueueIssue = enqueueIssue;
68
+ this.feed = feed;
69
+ }
70
+ async handle(run, issue) {
71
+ this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn - marking as failed");
72
+ const repairedCounters = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
73
+ if (run.runType === "ci_repair" && issue.ciRepairAttempts > 0) {
74
+ this.db.issueSessions.upsertIssueWithLease(lease, {
75
+ projectId: issue.projectId,
76
+ linearIssueId: issue.linearIssueId,
77
+ ciRepairAttempts: issue.ciRepairAttempts - 1,
78
+ });
79
+ }
80
+ else if (run.runType === "queue_repair" && issue.queueRepairAttempts > 0) {
81
+ this.db.issueSessions.upsertIssueWithLease(lease, {
82
+ projectId: issue.projectId,
83
+ linearIssueId: issue.linearIssueId,
84
+ queueRepairAttempts: issue.queueRepairAttempts - 1,
85
+ });
86
+ }
87
+ if (run.runType === "ci_repair" || run.runType === "queue_repair") {
88
+ this.db.issueSessions.upsertIssueWithLease(lease, {
89
+ projectId: issue.projectId,
90
+ linearIssueId: issue.linearIssueId,
91
+ lastAttemptedFailureHeadSha: null,
92
+ lastAttemptedFailureSignature: null,
93
+ });
94
+ }
95
+ return true;
96
+ });
97
+ if (!repairedCounters) {
98
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping interrupted-run recovery after losing issue-session lease");
99
+ this.releaseLease(run.projectId, run.linearIssueId);
100
+ return;
101
+ }
102
+ if (isRequestedChangesRunType(run.runType)) {
103
+ await this.handleInterruptedRequestedChangesRun(run, issue);
104
+ return;
105
+ }
106
+ const recoveredState = resolveRecoverablePostRunState(this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue);
107
+ this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
108
+ await this.restoreIdleWorktree(issue);
109
+ const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
110
+ if (recoveredState) {
111
+ this.feed?.publish({
112
+ level: "info",
113
+ kind: "stage",
114
+ issueKey: issue.issueKey,
115
+ projectId: run.projectId,
116
+ stage: recoveredState,
117
+ status: "reconciled",
118
+ summary: `Interrupted ${run.runType} recovered -> ${recoveredState}`,
119
+ });
120
+ }
121
+ else {
122
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
123
+ }
124
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
125
+ this.releaseLease(run.projectId, run.linearIssueId);
126
+ }
127
+ async handleInterruptedRequestedChangesRun(run, issue) {
128
+ const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
129
+ const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
130
+ const retryContext = await this.completionPolicy.resolveRequestedChangesWakeContext(refreshedIssue, run.runType, run.runType === "branch_upkeep"
131
+ ? {
132
+ branchUpkeepRequired: true,
133
+ reviewFixMode: "branch_upkeep",
134
+ wakeReason: "branch_upkeep",
135
+ }
136
+ : undefined);
137
+ const retryRunType = resolveRetryRunType(run.runType, retryContext);
138
+ const recoveredState = resolveRecoverablePostRunState(refreshedIssue) ?? "failed";
139
+ const interruptedMessage = "Requested-changes run was interrupted before PatchRelay could verify that a new PR head was published";
140
+ this.failRunAndClear(run, interruptedMessage, recoveredState);
141
+ await this.restoreIdleWorktree(issue);
142
+ const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
143
+ if (recoveredState === "changes_requested") {
144
+ this.db.issues.upsertIssue({
145
+ projectId: run.projectId,
146
+ linearIssueId: run.linearIssueId,
147
+ pendingRunType: retryRunType,
148
+ pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
149
+ });
150
+ this.feed?.publish({
151
+ level: "warn",
152
+ kind: "workflow",
153
+ issueKey: issue.issueKey,
154
+ projectId: run.projectId,
155
+ stage: run.runType,
156
+ status: "retry_queued",
157
+ summary: "Requested-changes run was interrupted; PatchRelay will retry from fresh GitHub truth",
158
+ });
159
+ this.enqueueIssue(run.projectId, run.linearIssueId);
160
+ }
161
+ else {
162
+ this.feed?.publish({
163
+ level: "error",
164
+ kind: "workflow",
165
+ issueKey: issue.issueKey,
166
+ projectId: run.projectId,
167
+ stage: run.runType,
168
+ status: "escalated",
169
+ summary: interruptedMessage,
170
+ });
171
+ }
172
+ void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, interruptedMessage));
173
+ void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
174
+ this.releaseLease(run.projectId, run.linearIssueId);
175
+ }
176
+ }