patchrelay 0.36.8 → 0.36.10

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.
@@ -130,14 +130,15 @@ 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
+ const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
139
140
  // Update PR state on the issue
140
- this.db.upsertIssue({
141
+ this.db.issues.upsertIssue({
141
142
  projectId: issue.projectId,
142
143
  linearIssueId: issue.linearIssueId,
143
144
  ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
@@ -146,7 +147,7 @@ export class GitHubWebhookHandler {
146
147
  ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
147
148
  ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
148
149
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
149
- ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
150
+ ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
150
151
  ...(event.reviewState === "changes_requested"
151
152
  ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
152
153
  : event.reviewState === "approved"
@@ -158,7 +159,7 @@ export class GitHubWebhookHandler {
158
159
  const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
159
160
  if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
160
161
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
161
- const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
162
+ const afterMetadata = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
162
163
  const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
163
164
  // Only transition and notify when the state actually changes.
164
165
  // Multiple check_suite events can arrive for the same outcome.
@@ -169,13 +170,13 @@ export class GitHubWebhookHandler {
169
170
  factoryState: newState,
170
171
  });
171
172
  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;
173
+ const transitionedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
173
174
  void this.emitLinearActivity(transitionedIssue, newState, event);
174
175
  void this.syncLinearSession(transitionedIssue);
175
176
  }
176
177
  }
177
178
  // Re-read issue after all upserts so reactive run logic sees current state
178
- const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
179
+ const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
179
180
  // Reset repair counters on new push — but only when no repair run is active,
180
181
  // since Codex pushes during repair and resetting mid-run would bypass budgets.
181
182
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
@@ -240,7 +241,7 @@ export class GitHubWebhookHandler {
240
241
  }
241
242
  async updateCiSnapshot(issue, event, project) {
242
243
  if (event.triggerEvent === "pr_merged") {
243
- this.db.upsertIssue({
244
+ this.db.issues.upsertIssue({
244
245
  projectId: issue.projectId,
245
246
  linearIssueId: issue.linearIssueId,
246
247
  lastGitHubCiSnapshotHeadSha: null,
@@ -252,7 +253,7 @@ export class GitHubWebhookHandler {
252
253
  return;
253
254
  }
254
255
  if (event.triggerEvent === "pr_synchronize") {
255
- this.db.upsertIssue({
256
+ this.db.issues.upsertIssue({
256
257
  projectId: issue.projectId,
257
258
  linearIssueId: issue.linearIssueId,
258
259
  lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
@@ -279,7 +280,7 @@ export class GitHubWebhookHandler {
279
280
  gateCheckNames: this.getGateCheckNames(project),
280
281
  });
281
282
  if (!snapshot) {
282
- this.db.upsertIssue({
283
+ this.db.issues.upsertIssue({
283
284
  projectId: issue.projectId,
284
285
  linearIssueId: issue.linearIssueId,
285
286
  lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
@@ -300,7 +301,7 @@ export class GitHubWebhookHandler {
300
301
  });
301
302
  return;
302
303
  }
303
- this.db.upsertIssue({
304
+ this.db.issues.upsertIssue({
304
305
  projectId: issue.projectId,
305
306
  linearIssueId: issue.linearIssueId,
306
307
  prCheckStatus: snapshot.gateCheckStatus,
@@ -341,7 +342,7 @@ export class GitHubWebhookHandler {
341
342
  return;
342
343
  }
343
344
  const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
344
- this.db.upsertIssue({
345
+ this.db.issues.upsertIssue({
345
346
  projectId: issue.projectId,
346
347
  linearIssueId: issue.linearIssueId,
347
348
  lastGitHubFailureSource: "queue_eviction",
@@ -399,7 +400,7 @@ export class GitHubWebhookHandler {
399
400
  }
400
401
  const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
401
402
  const snapshot = this.getRelevantCiSnapshot(issue, event);
402
- this.db.upsertIssue({
403
+ this.db.issues.upsertIssue({
403
404
  projectId: issue.projectId,
404
405
  linearIssueId: issue.linearIssueId,
405
406
  lastGitHubFailureSource: "branch_ci",
@@ -520,7 +521,7 @@ export class GitHubWebhookHandler {
520
521
  : "Pull request closed during active run",
521
522
  });
522
523
  }
523
- this.db.upsertIssue({
524
+ this.db.issues.upsertIssue({
524
525
  projectId: issue.projectId,
525
526
  linearIssueId: issue.linearIssueId,
526
527
  activeRunId: null,
@@ -535,7 +536,7 @@ export class GitHubWebhookHandler {
535
536
  this.db.transaction(commitTerminalUpdate);
536
537
  }
537
538
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
538
- const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
539
+ const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
539
540
  if (event.triggerEvent === "pr_merged") {
540
541
  await this.completeLinearIssueAfterMerge(updatedIssue);
541
542
  }
@@ -554,7 +555,7 @@ export class GitHubWebhookHandler {
554
555
  }
555
556
  const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
556
557
  if (normalizedCurrent === targetState.trim().toLowerCase()) {
557
- this.db.upsertIssue({
558
+ this.db.issues.upsertIssue({
558
559
  projectId: issue.projectId,
559
560
  linearIssueId: issue.linearIssueId,
560
561
  ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
@@ -563,7 +564,7 @@ export class GitHubWebhookHandler {
563
564
  return;
564
565
  }
565
566
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
566
- this.db.upsertIssue({
567
+ this.db.issues.upsertIssue({
567
568
  projectId: issue.projectId,
568
569
  linearIssueId: issue.linearIssueId,
569
570
  ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
@@ -587,7 +588,7 @@ export class GitHubWebhookHandler {
587
588
  const failureContext = source === "queue_eviction"
588
589
  ? this.buildQueueFailureContext(issue, event)
589
590
  : await this.resolveBranchFailureContext(issue, event, project);
590
- this.db.upsertIssue({
591
+ this.db.issues.upsertIssue({
591
592
  projectId: issue.projectId,
592
593
  linearIssueId: issue.linearIssueId,
593
594
  lastGitHubFailureSource: source,
@@ -614,7 +615,7 @@ export class GitHubWebhookHandler {
614
615
  if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
615
616
  return;
616
617
  }
617
- this.db.upsertIssue({
618
+ this.db.issues.upsertIssue({
618
619
  projectId: issue.projectId,
619
620
  linearIssueId: issue.linearIssueId,
620
621
  lastGitHubFailureSource: null,
@@ -730,6 +731,21 @@ export class GitHubWebhookHandler {
730
731
  const normalized = event.checkName.trim().toLowerCase();
731
732
  return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
732
733
  }
734
+ deriveImmediatePrCheckStatus(issue, event, project) {
735
+ if (event.triggerEvent === "pr_synchronize") {
736
+ return "pending";
737
+ }
738
+ if (event.eventSource !== "check_run") {
739
+ return undefined;
740
+ }
741
+ if (!this.isGateCheckEvent(event, project)) {
742
+ return undefined;
743
+ }
744
+ if (this.isStaleGateEvent(issue, event)) {
745
+ return undefined;
746
+ }
747
+ return event.checkStatus;
748
+ }
733
749
  isStaleGateEvent(issue, event) {
734
750
  return Boolean(issue.lastGitHubCiSnapshotHeadSha
735
751
  && event.headSha
@@ -763,7 +779,7 @@ export class GitHubWebhookHandler {
763
779
  return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
764
780
  }
765
781
  getRelevantCiSnapshot(issue, event) {
766
- const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
782
+ const snapshot = this.db.issues.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
767
783
  if (!snapshot)
768
784
  return undefined;
769
785
  if (snapshot.headSha !== event.headSha)
@@ -904,7 +920,7 @@ export class GitHubWebhookHandler {
904
920
  const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
905
921
  if (!prNumber)
906
922
  return;
907
- const issue = this.db.getIssueByPrNumber(prNumber);
923
+ const issue = this.db.issues.getIssueByPrNumber(prNumber);
908
924
  if (!issue)
909
925
  return;
910
926
  if (!this.isPatchRelayOwnedPr(issue))
@@ -4,6 +4,7 @@ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
6
  import { execCommand } from "./utils.js";
7
+ const DEFAULT_REVIEW_FIX_BUDGET = 12;
7
8
  function isFailingCheckStatus(status) {
8
9
  return status === "failed" || status === "failure";
9
10
  }
@@ -110,7 +111,7 @@ export class IdleIssueReconciler {
110
111
  this.feed = feed;
111
112
  }
112
113
  async reconcile() {
113
- for (const issue of this.db.listIdleNonTerminalIssues()) {
114
+ for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
114
115
  if (issue.prState === "merged") {
115
116
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
116
117
  continue;
@@ -145,13 +146,13 @@ export class IdleIssueReconciler {
145
146
  await this.reconcileFromGitHub(issue);
146
147
  }
147
148
  }
148
- for (const issue of this.db.listIssues()) {
149
+ for (const issue of this.db.issues.listIssues()) {
149
150
  if (!this.shouldProbeTerminalIssueFromGitHub(issue))
150
151
  continue;
151
152
  await this.reconcileFromGitHub(issue);
152
153
  }
153
- for (const issue of this.db.listBlockedDelegatedIssues()) {
154
- const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
154
+ for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
155
+ const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
155
156
  if (unresolved === 0) {
156
157
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
157
158
  projectId: issue.projectId,
@@ -179,7 +180,7 @@ export class IdleIssueReconciler {
179
180
  return;
180
181
  }
181
182
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
182
- this.db.upsertIssue({
183
+ this.db.issues.upsertIssue({
183
184
  projectId: issue.projectId,
184
185
  linearIssueId: issue.linearIssueId,
185
186
  factoryState: newState,
@@ -206,7 +207,7 @@ export class IdleIssueReconciler {
206
207
  });
207
208
  const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
208
209
  if (branchOwner) {
209
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
210
+ this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
210
211
  }
211
212
  if (options?.pendingRunType) {
212
213
  this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
@@ -309,7 +310,7 @@ export class IdleIssueReconciler {
309
310
  ?? (inferred === "queue_eviction" && failureHeadSha && checkName
310
311
  ? ["queue_eviction", failureHeadSha, checkName].join("::")
311
312
  : null);
312
- this.db.upsertIssue({
313
+ this.db.issues.upsertIssue({
313
314
  projectId: issue.projectId,
314
315
  linearIssueId: issue.linearIssueId,
315
316
  lastGitHubFailureSource: inferred,
@@ -317,7 +318,7 @@ export class IdleIssueReconciler {
317
318
  ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
318
319
  ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
319
320
  });
320
- const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
321
+ const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
321
322
  if (!refreshed)
322
323
  return issue;
323
324
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
@@ -337,7 +338,7 @@ export class IdleIssueReconciler {
337
338
  const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
338
339
  const failureSignature = issue.lastGitHubFailureSignature
339
340
  ?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
340
- this.db.upsertIssue({
341
+ this.db.issues.upsertIssue({
341
342
  projectId: issue.projectId,
342
343
  linearIssueId: issue.linearIssueId,
343
344
  lastGitHubFailureSource: "queue_eviction",
@@ -345,7 +346,7 @@ export class IdleIssueReconciler {
345
346
  ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
346
347
  ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
347
348
  });
348
- const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
349
+ const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
349
350
  if (!refreshed)
350
351
  return issue;
351
352
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
@@ -410,7 +411,7 @@ export class IdleIssueReconciler {
410
411
  const previousHeadSha = issue.prHeadSha;
411
412
  const gateCheckNames = getGateCheckNames(project);
412
413
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
413
- this.db.upsertIssue({
414
+ this.db.issues.upsertIssue({
414
415
  projectId: issue.projectId,
415
416
  linearIssueId: issue.linearIssueId,
416
417
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
@@ -433,13 +434,13 @@ export class IdleIssueReconciler {
433
434
  : {}),
434
435
  });
435
436
  if (pr.state === "MERGED") {
436
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
437
+ this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
437
438
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
438
439
  return;
439
440
  }
440
441
  if (pr.state === "CLOSED") {
441
442
  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" });
443
+ this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
443
444
  this.advanceIdleIssue(issue, "delegated", {
444
445
  pendingRunType: "implementation",
445
446
  clearFailureProvenance: true,
@@ -481,7 +482,7 @@ export class IdleIssueReconciler {
481
482
  }
482
483
  const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" || pr.reviewDecision === "APPROVED";
483
484
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
484
- const refreshedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
485
+ const refreshedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
485
486
  const reactiveIntent = deriveIssueSessionReactiveIntent({
486
487
  prNumber: refreshedIssue.prNumber,
487
488
  prState: refreshedIssue.prState,
@@ -493,6 +494,16 @@ export class IdleIssueReconciler {
493
494
  });
494
495
  if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
495
496
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
497
+ if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
498
+ this.logger.debug({
499
+ issueKey: issue.issueKey,
500
+ prNumber: issue.prNumber,
501
+ from: issue.factoryState,
502
+ runType: reactiveIntent.runType,
503
+ reviewFixAttempts: issue.reviewFixAttempts,
504
+ }, "Reconciliation: leaving terminal requested-changes issue escalated because the repair budget is exhausted");
505
+ return;
506
+ }
496
507
  const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
497
508
  ? buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
498
509
  : undefined;
@@ -549,7 +560,7 @@ export class IdleIssueReconciler {
549
560
  return;
550
561
  }
551
562
  if (isReviewDecisionApproved(pr.reviewDecision)) {
552
- this.db.upsertIssue({
563
+ this.db.issues.upsertIssue({
553
564
  projectId: issue.projectId,
554
565
  linearIssueId: issue.linearIssueId,
555
566
  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
+ }
@@ -71,7 +71,7 @@ export class IssueQueryService {
71
71
  const legacy = this.db.getIssueOverview(issueKey);
72
72
  if (!legacy)
73
73
  return undefined;
74
- const issueRecord = this.db.getIssueByKey(issueKey);
74
+ const issueRecord = this.db.issues.getIssueByKey(issueKey);
75
75
  const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
76
76
  const activeRun = activeStatus?.run ?? legacy.activeRun;
77
77
  const latestRun = this.db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
@@ -123,8 +123,8 @@ export class IssueQueryService {
123
123
  : {}),
124
124
  };
125
125
  }
126
- const issueRecord = this.db.getIssueByKey(issueKey);
127
- const blockedBy = this.db.listIssueDependencies(session.projectId, session.linearIssueId);
126
+ const issueRecord = this.db.issues.getIssueByKey(issueKey);
127
+ const blockedBy = this.db.issues.listIssueDependencies(session.projectId, session.linearIssueId);
128
128
  const unresolvedBlockedBy = blockedBy.filter((entry) => (entry.blockerCurrentLinearStateType !== "completed"
129
129
  && entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done"));
130
130
  const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
@@ -231,7 +231,7 @@ export class IssueQueryService {
231
231
  const overview = await this.getIssueOverview(issueKey);
232
232
  if (!overview)
233
233
  return undefined;
234
- const issueRecord = this.db.getIssueByKey(issueKey);
234
+ const issueRecord = this.db.issues.getIssueByKey(issueKey);
235
235
  const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
236
236
  const runs = (overview.runs ?? this.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
237
237
  run: {
@@ -0,0 +1,114 @@
1
+ import { isoNow } from "./db/shared.js";
2
+ import { buildTrackedIssueRecord } from "./tracked-issue-projector.js";
3
+ import { extractLatestAssistantSummary, } from "./issue-session-events.js";
4
+ import { deriveIssueSessionState, deriveIssueSessionWakeReason, } from "./issue-session.js";
5
+ export function syncIssueSessionFromIssue(params) {
6
+ const { connection, issues, issueSessions, runs, issue, options } = params;
7
+ const existing = issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
8
+ const latestRun = runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
9
+ const latestRunType = options?.lastRunType ?? latestRun?.runType ?? existing?.lastRunType;
10
+ const summaryText = resolveIssueSessionSummary(issue, runs, latestRun, existing?.summaryText, options?.summaryText);
11
+ const activeThreadId = issue.threadId ?? existing?.activeThreadId;
12
+ const threadGeneration = activeThreadId && activeThreadId !== existing?.activeThreadId
13
+ ? (existing?.threadGeneration ?? 0) + 1
14
+ : (existing?.threadGeneration ?? (activeThreadId ? 1 : 0));
15
+ const sessionState = deriveIssueSessionState({
16
+ ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
17
+ factoryState: issue.factoryState,
18
+ });
19
+ const tracked = buildTrackedIssueRecord({
20
+ issue,
21
+ session: existing,
22
+ blockedBy: issues.listIssueDependencies(issue.projectId, issue.linearIssueId),
23
+ hasPendingWake: issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined,
24
+ latestRun,
25
+ latestEvent: issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { limit: 1 }).at(-1),
26
+ });
27
+ const lastWakeReason = options?.lastWakeReason
28
+ ?? deriveIssueSessionWakeReason({
29
+ pendingRunType: issue.pendingRunType,
30
+ factoryState: issue.factoryState,
31
+ prNumber: issue.prNumber,
32
+ prState: issue.prState,
33
+ prReviewState: issue.prReviewState,
34
+ prCheckStatus: issue.prCheckStatus,
35
+ latestFailureSource: issue.lastGitHubFailureSource,
36
+ })
37
+ ?? existing?.lastWakeReason;
38
+ const now = isoNow();
39
+ if (existing) {
40
+ connection.prepare(`
41
+ UPDATE issue_sessions SET
42
+ issue_key = ?,
43
+ repo_id = ?,
44
+ branch_name = ?,
45
+ worktree_path = ?,
46
+ pr_number = ?,
47
+ pr_head_sha = ?,
48
+ pr_author_login = ?,
49
+ session_state = ?,
50
+ waiting_reason = ?,
51
+ summary_text = ?,
52
+ active_thread_id = ?,
53
+ thread_generation = ?,
54
+ active_run_id = ?,
55
+ last_run_type = ?,
56
+ last_wake_reason = ?,
57
+ ci_repair_attempts = ?,
58
+ queue_repair_attempts = ?,
59
+ review_fix_attempts = ?,
60
+ updated_at = ?
61
+ WHERE project_id = ? AND linear_issue_id = ?
62
+ `).run(issue.issueKey ?? null, issue.projectId, issue.branchName ?? null, issue.worktreePath ?? null, issue.prNumber ?? null, issue.prHeadSha ?? null, issue.prAuthorLogin ?? null, sessionState, tracked.waitingReason ?? null, summaryText ?? null, activeThreadId ?? null, threadGeneration, issue.activeRunId ?? null, latestRunType ?? null, lastWakeReason ?? null, issue.ciRepairAttempts, issue.queueRepairAttempts, issue.reviewFixAttempts, now, issue.projectId, issue.linearIssueId);
63
+ return;
64
+ }
65
+ connection.prepare(`
66
+ INSERT INTO issue_sessions (
67
+ project_id, linear_issue_id, issue_key, repo_id, branch_name, worktree_path,
68
+ pr_number, pr_head_sha, pr_author_login, session_state, waiting_reason, summary_text,
69
+ active_thread_id, thread_generation, active_run_id, last_run_type, last_wake_reason,
70
+ ci_repair_attempts, queue_repair_attempts, review_fix_attempts,
71
+ created_at, updated_at
72
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ `).run(issue.projectId, issue.linearIssueId, issue.issueKey ?? null, issue.projectId, issue.branchName ?? null, issue.worktreePath ?? null, issue.prNumber ?? null, issue.prHeadSha ?? null, issue.prAuthorLogin ?? null, sessionState, tracked.waitingReason ?? null, summaryText ?? null, activeThreadId ?? null, threadGeneration, issue.activeRunId ?? null, latestRunType ?? null, lastWakeReason ?? null, issue.ciRepairAttempts, issue.queueRepairAttempts, issue.reviewFixAttempts, now, now);
74
+ }
75
+ function resolveIssueSessionSummary(issue, runs, latestRun, existingSummaryText, explicitSummaryText) {
76
+ if (explicitSummaryText?.trim()) {
77
+ return explicitSummaryText;
78
+ }
79
+ const latestSummary = extractLatestAssistantSummary(latestRun);
80
+ if (latestRun && (latestRun.status === "queued" || latestRun.status === "running")) {
81
+ return latestSummary;
82
+ }
83
+ if (shouldKeepPreviousIssueSummary(issue, latestRun)) {
84
+ return findLatestCompletedRunSummary(runs, issue.projectId, issue.linearIssueId)
85
+ ?? existingSummaryText
86
+ ?? latestSummary;
87
+ }
88
+ return latestSummary ?? existingSummaryText;
89
+ }
90
+ function shouldKeepPreviousIssueSummary(issue, latestRun) {
91
+ if (!latestRun || latestRun.status !== "failed") {
92
+ return false;
93
+ }
94
+ if (latestRun.summaryJson || latestRun.reportJson) {
95
+ return false;
96
+ }
97
+ return issue.factoryState === "pr_open"
98
+ || issue.factoryState === "awaiting_queue"
99
+ || issue.factoryState === "done";
100
+ }
101
+ function findLatestCompletedRunSummary(runs, projectId, linearIssueId) {
102
+ const issueRuns = runs.listRunsForIssue(projectId, linearIssueId);
103
+ for (let index = issueRuns.length - 1; index >= 0; index -= 1) {
104
+ const run = issueRuns[index];
105
+ if (!run || run.status !== "completed") {
106
+ continue;
107
+ }
108
+ const summary = extractLatestAssistantSummary(run);
109
+ if (summary?.trim()) {
110
+ return summary;
111
+ }
112
+ }
113
+ return undefined;
114
+ }