patchrelay 0.36.7 → 0.36.8

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.
@@ -0,0 +1,203 @@
1
+ import { buildRunFailureActivity } from "./linear-session-reporting.js";
2
+ const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
3
+ const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
4
+ export class RunRecoveryService {
5
+ db;
6
+ logger;
7
+ linearSync;
8
+ releaseLease;
9
+ enqueueIssue;
10
+ resolveBranchOwnerForStateTransition;
11
+ feed;
12
+ constructor(db, logger, linearSync, releaseLease, enqueueIssue, resolveBranchOwnerForStateTransition, feed) {
13
+ this.db = db;
14
+ this.logger = logger;
15
+ this.linearSync = linearSync;
16
+ this.releaseLease = releaseLease;
17
+ this.enqueueIssue = enqueueIssue;
18
+ this.resolveBranchOwnerForStateTransition = resolveBranchOwnerForStateTransition;
19
+ this.feed = feed;
20
+ }
21
+ recoverOrEscalate(params) {
22
+ const { issue, runType, reason } = params;
23
+ const fresh = this.db.getIssue(issue.projectId, issue.linearIssueId);
24
+ if (!fresh)
25
+ return;
26
+ if (params.isRequestedChangesRunType(runType)) {
27
+ const updated = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
28
+ this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
29
+ this.db.issueSessions.upsertIssueWithLease(lease, {
30
+ projectId: fresh.projectId,
31
+ linearIssueId: fresh.linearIssueId,
32
+ pendingRunType: null,
33
+ pendingRunContextJson: null,
34
+ factoryState: "escalated",
35
+ });
36
+ return true;
37
+ });
38
+ if (!updated) {
39
+ this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping review-fix recovery escalation after losing issue-session lease");
40
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
41
+ return;
42
+ }
43
+ this.logger.warn({ issueKey: fresh.issueKey, reason }, "Requested-changes run failed before a new head was published - escalating");
44
+ this.feed?.publish({
45
+ level: "error",
46
+ kind: "workflow",
47
+ issueKey: fresh.issueKey,
48
+ projectId: fresh.projectId,
49
+ stage: runType,
50
+ status: "escalated",
51
+ summary: `Requested-changes run failed before publishing a new head (${reason})`,
52
+ });
53
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
54
+ return;
55
+ }
56
+ if (fresh.prState === "merged") {
57
+ const updated = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
58
+ this.db.issueSessions.upsertIssueWithLease(lease, {
59
+ projectId: fresh.projectId,
60
+ linearIssueId: fresh.linearIssueId,
61
+ factoryState: "done",
62
+ zombieRecoveryAttempts: 0,
63
+ lastZombieRecoveryAt: null,
64
+ });
65
+ return true;
66
+ });
67
+ if (!updated) {
68
+ this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping merged recovery completion after losing issue-session lease");
69
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
70
+ return;
71
+ }
72
+ this.logger.info({ issueKey: fresh.issueKey, reason }, "Recovery: PR already merged - transitioning to done");
73
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
74
+ return;
75
+ }
76
+ const attempts = fresh.zombieRecoveryAttempts + 1;
77
+ if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
78
+ const updated = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
79
+ this.db.issueSessions.upsertIssueWithLease(lease, {
80
+ projectId: fresh.projectId,
81
+ linearIssueId: fresh.linearIssueId,
82
+ factoryState: "escalated",
83
+ });
84
+ return true;
85
+ });
86
+ if (!updated) {
87
+ this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery escalation after losing issue-session lease");
88
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
89
+ return;
90
+ }
91
+ this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: budget exhausted - escalating");
92
+ this.feed?.publish({
93
+ level: "error",
94
+ kind: "workflow",
95
+ issueKey: fresh.issueKey,
96
+ projectId: fresh.projectId,
97
+ stage: "escalated",
98
+ status: "budget_exhausted",
99
+ summary: `${reason} recovery failed after ${DEFAULT_ZOMBIE_RECOVERY_BUDGET} attempts`,
100
+ });
101
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
102
+ return;
103
+ }
104
+ if (fresh.lastZombieRecoveryAt) {
105
+ const elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
106
+ const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
107
+ if (elapsed < delay) {
108
+ this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
109
+ return;
110
+ }
111
+ }
112
+ const requeued = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
113
+ this.db.issueSessions.upsertIssueWithLease(lease, {
114
+ projectId: fresh.projectId,
115
+ linearIssueId: fresh.linearIssueId,
116
+ pendingRunType: null,
117
+ pendingRunContextJson: null,
118
+ zombieRecoveryAttempts: attempts,
119
+ lastZombieRecoveryAt: new Date().toISOString(),
120
+ });
121
+ return params.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
122
+ });
123
+ if (!requeued) {
124
+ this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
125
+ this.releaseLease(fresh.projectId, fresh.linearIssueId);
126
+ return;
127
+ }
128
+ this.enqueueIssue(fresh.projectId, fresh.linearIssueId);
129
+ this.logger.info({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: re-enqueued with backoff");
130
+ }
131
+ escalate(params) {
132
+ const { issue, runType, reason } = params;
133
+ this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
134
+ const escalated = params.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
135
+ if (issue.activeRunId) {
136
+ this.db.issueSessions.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
137
+ }
138
+ this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
139
+ this.db.issueSessions.upsertIssueWithLease(lease, {
140
+ projectId: issue.projectId,
141
+ linearIssueId: issue.linearIssueId,
142
+ pendingRunType: null,
143
+ pendingRunContextJson: null,
144
+ activeRunId: null,
145
+ factoryState: "escalated",
146
+ });
147
+ return true;
148
+ });
149
+ if (!escalated) {
150
+ this.logger.warn({ issueKey: issue.issueKey, runType }, "Skipping escalation write after losing issue-session lease");
151
+ this.releaseLease(issue.projectId, issue.linearIssueId);
152
+ return;
153
+ }
154
+ this.feed?.publish({
155
+ level: "error",
156
+ kind: "workflow",
157
+ issueKey: issue.issueKey,
158
+ projectId: issue.projectId,
159
+ stage: runType,
160
+ status: "escalated",
161
+ summary: `Escalated: ${reason}`,
162
+ });
163
+ const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
164
+ void this.linearSync.emitActivity(escalatedIssue, {
165
+ type: "error",
166
+ body: `PatchRelay needs human help to continue.\n\n${reason}`,
167
+ });
168
+ void this.linearSync.syncSession(escalatedIssue);
169
+ this.releaseLease(issue.projectId, issue.linearIssueId);
170
+ }
171
+ failRunAndClear(params) {
172
+ const { run, message, nextState } = params;
173
+ const updated = params.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
174
+ this.db.runs.finishRun(run.id, { status: "failed", failureReason: message });
175
+ if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
176
+ this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
177
+ }
178
+ this.db.upsertIssue({
179
+ projectId: run.projectId,
180
+ linearIssueId: run.linearIssueId,
181
+ activeRunId: null,
182
+ factoryState: nextState,
183
+ });
184
+ const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
185
+ if (branchOwner) {
186
+ const heldLease = params.getHeldLease(run.projectId, run.linearIssueId);
187
+ if (heldLease) {
188
+ this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
189
+ }
190
+ }
191
+ return true;
192
+ });
193
+ if (!updated) {
194
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failure cleanup after losing issue-session lease");
195
+ }
196
+ this.releaseLease(run.projectId, run.linearIssueId);
197
+ }
198
+ emitInterruptedFailure(runType, issue, message) {
199
+ const latest = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
200
+ void this.linearSync.emitActivity(latest, buildRunFailureActivity(runType, message));
201
+ void this.linearSync.syncSession(latest, { activeRunType: runType });
202
+ }
203
+ }
@@ -0,0 +1,101 @@
1
+ const DEFAULT_CI_REPAIR_BUDGET = 3;
2
+ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
3
+ const DEFAULT_REVIEW_FIX_BUDGET = 12;
4
+ export class RunWakePlanner {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ resolveRunWake(issue) {
10
+ const sessionWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
11
+ if (!sessionWake)
12
+ return undefined;
13
+ return {
14
+ runType: sessionWake.runType,
15
+ context: sessionWake.context,
16
+ wakeReason: sessionWake.wakeReason,
17
+ resumeThread: sessionWake.resumeThread,
18
+ eventIds: sessionWake.eventIds,
19
+ };
20
+ }
21
+ appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
22
+ let eventType;
23
+ let dedupeKey;
24
+ if (runType === "queue_repair") {
25
+ eventType = "merge_steward_incident";
26
+ dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
27
+ }
28
+ else if (runType === "ci_repair") {
29
+ eventType = "settled_red_ci";
30
+ dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
31
+ }
32
+ else if (runType === "review_fix" || runType === "branch_upkeep") {
33
+ eventType = "review_changes_requested";
34
+ dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
35
+ }
36
+ else {
37
+ eventType = "delegated";
38
+ dedupeKey = `${dedupeScope ?? "wake"}:implementation:${issue.linearIssueId}`;
39
+ }
40
+ return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
41
+ projectId: issue.projectId,
42
+ linearIssueId: issue.linearIssueId,
43
+ eventType,
44
+ ...(context ? { eventJson: JSON.stringify(context) } : {}),
45
+ dedupeKey,
46
+ }));
47
+ }
48
+ materializeLegacyPendingWake(issue, lease) {
49
+ if (!issue.pendingRunType)
50
+ return issue;
51
+ const context = issue.pendingRunContextJson
52
+ ? JSON.parse(issue.pendingRunContextJson)
53
+ : undefined;
54
+ this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
55
+ const updated = this.db.issueSessions.upsertIssueWithLease(lease, {
56
+ projectId: issue.projectId,
57
+ linearIssueId: issue.linearIssueId,
58
+ pendingRunType: null,
59
+ pendingRunContextJson: null,
60
+ });
61
+ if (!updated)
62
+ return issue;
63
+ return this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
64
+ }
65
+ budgetExceeded(issue, runType, isRequestedChangesRunType) {
66
+ if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
67
+ return `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`;
68
+ }
69
+ if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
70
+ return `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`;
71
+ }
72
+ if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
73
+ return `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`;
74
+ }
75
+ return undefined;
76
+ }
77
+ incrementAttemptCounters(issue, lease, runType, isRequestedChangesRunType) {
78
+ if (runType === "ci_repair") {
79
+ return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
80
+ projectId: issue.projectId,
81
+ linearIssueId: issue.linearIssueId,
82
+ ciRepairAttempts: issue.ciRepairAttempts + 1,
83
+ }));
84
+ }
85
+ if (runType === "queue_repair") {
86
+ return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
87
+ projectId: issue.projectId,
88
+ linearIssueId: issue.linearIssueId,
89
+ queueRepairAttempts: issue.queueRepairAttempts + 1,
90
+ }));
91
+ }
92
+ if (isRequestedChangesRunType(runType)) {
93
+ return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
94
+ projectId: issue.projectId,
95
+ linearIssueId: issue.linearIssueId,
96
+ reviewFixAttempts: issue.reviewFixAttempts + 1,
97
+ }));
98
+ }
99
+ return true;
100
+ }
101
+ }
package/dist/service.js CHANGED
@@ -132,7 +132,7 @@ export class PatchRelayService {
132
132
  });
133
133
  }
134
134
  async start() {
135
- this.db.releaseExpiredIssueSessionLeases();
135
+ this.db.issueSessions.releaseExpiredIssueSessionLeases();
136
136
  const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
137
137
  for (const repair of repairedInstallations) {
138
138
  this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
@@ -189,7 +189,7 @@ export class PatchRelayService {
189
189
  const syncedIssue = issue.agentSessionId
190
190
  ? issue
191
191
  : (() => {
192
- const recoveredAgentSessionId = this.db.findLatestAgentSessionIdForIssue(issue.linearIssueId);
192
+ const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
193
193
  return recoveredAgentSessionId
194
194
  ? this.db.upsertIssue({
195
195
  projectId: issue.projectId,
@@ -201,7 +201,7 @@ export class PatchRelayService {
201
201
  if (!syncedIssue.agentSessionId) {
202
202
  continue;
203
203
  }
204
- const activeRun = syncedIssue.activeRunId ? this.db.getRun(syncedIssue.activeRunId) : undefined;
204
+ const activeRun = syncedIssue.activeRunId ? this.db.runs.getRunById(syncedIssue.activeRunId) : undefined;
205
205
  await this.orchestrator.linearSync.syncSession(syncedIssue, activeRun ? { activeRunType: activeRun.runType } : undefined);
206
206
  }
207
207
  }
@@ -237,7 +237,7 @@ export class PatchRelayService {
237
237
  const unresolvedBlockers = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
238
238
  const shouldRecoverAwaitingInput = delegated
239
239
  && issue.factoryState === "awaiting_input"
240
- && this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
240
+ && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
241
241
  const updated = this.db.upsertIssue({
242
242
  projectId: issue.projectId,
243
243
  linearIssueId: issue.linearIssueId,
@@ -255,13 +255,13 @@ export class PatchRelayService {
255
255
  continue;
256
256
  }
257
257
  if (unresolvedBlockers === 0) {
258
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
258
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
259
259
  projectId: issue.projectId,
260
260
  linearIssueId: issue.linearIssueId,
261
261
  eventType: "delegated",
262
262
  dedupeKey: `delegated:${issue.linearIssueId}`,
263
263
  });
264
- if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
264
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
265
265
  this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
266
266
  }
267
267
  this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from stale awaiting_input state and re-queued implementation");
@@ -427,7 +427,7 @@ export class PatchRelayService {
427
427
  const blockedByCount = Number(row.blocked_by_count ?? 0);
428
428
  const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
429
429
  const hasPendingWake = hasPendingSessionEvents
430
- || this.db.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
430
+ || this.db.issueSessions.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
431
431
  const readyForExecution = isIssueSessionReadyForExecution({
432
432
  ...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
433
433
  factoryState: String(row.factory_state ?? "delegated"),
@@ -473,7 +473,7 @@ export class PatchRelayService {
473
473
  startedAt: String(row.updated_at),
474
474
  }
475
475
  : undefined;
476
- const latestEvent = this.db.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
476
+ const latestEvent = this.db.issueSessions.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
477
477
  const statusNoteCandidate = deriveIssueStatusNote({
478
478
  issue: { factoryState: String(row.factory_state ?? "delegated") },
479
479
  sessionSummary,
@@ -536,7 +536,7 @@ export class PatchRelayService {
536
536
  });
537
537
  // If no active run, queue as pending context for the next run
538
538
  if (!issue.activeRunId) {
539
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
539
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
540
540
  projectId: issue.projectId,
541
541
  linearIssueId: issue.linearIssueId,
542
542
  eventType: "operator_prompt",
@@ -545,7 +545,7 @@ export class PatchRelayService {
545
545
  this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
546
546
  return { delivered: false, queued: true };
547
547
  }
548
- const run = this.db.getRun(issue.activeRunId);
548
+ const run = this.db.runs.getRunById(issue.activeRunId);
549
549
  if (!run?.threadId || !run.turnId) {
550
550
  return { error: "Active run has no thread or turn yet" };
551
551
  }
@@ -561,7 +561,7 @@ export class PatchRelayService {
561
561
  // Turn may have completed between check and steer — queue for next run
562
562
  const msg = error instanceof Error ? error.message : String(error);
563
563
  this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
564
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
564
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
565
565
  projectId: issue.projectId,
566
566
  linearIssueId: issue.linearIssueId,
567
567
  eventType: "operator_prompt",
@@ -577,7 +577,7 @@ export class PatchRelayService {
577
577
  return undefined;
578
578
  if (!issue.activeRunId)
579
579
  return { error: "No active run to stop" };
580
- const run = this.db.getRun(issue.activeRunId);
580
+ const run = this.db.runs.getRunById(issue.activeRunId);
581
581
  if (run?.threadId && run.turnId) {
582
582
  try {
583
583
  await this.codex.steerTurn({
@@ -590,14 +590,14 @@ export class PatchRelayService {
590
590
  // Turn may already be done
591
591
  }
592
592
  }
593
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
593
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
594
594
  projectId: issue.projectId,
595
595
  linearIssueId: issue.linearIssueId,
596
596
  eventType: "stop_requested",
597
597
  dedupeKey: `operator_stop:${issue.linearIssueId}`,
598
598
  });
599
- this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
600
- this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
599
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
600
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
601
601
  projectId: issue.projectId,
602
602
  linearIssueId: issue.linearIssueId,
603
603
  factoryState: "awaiting_input",
@@ -618,9 +618,9 @@ export class PatchRelayService {
618
618
  return undefined;
619
619
  if (issue.activeRunId)
620
620
  return { error: "Issue already has an active run" };
621
- const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
621
+ const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
622
622
  if (issue.prState === "merged") {
623
- this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
623
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
624
624
  projectId: issue.projectId,
625
625
  linearIssueId: issue.linearIssueId,
626
626
  factoryState: "done",
@@ -650,7 +650,7 @@ export class PatchRelayService {
650
650
  factoryState = "implementing";
651
651
  }
652
652
  this.appendOperatorRetryEvent(issue, runType);
653
- this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
653
+ this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
654
654
  projectId: issue.projectId,
655
655
  linearIssueId: issue.linearIssueId,
656
656
  factoryState: factoryState,
@@ -664,7 +664,7 @@ export class PatchRelayService {
664
664
  status: "retry",
665
665
  summary: `Retry queued: ${runType}`,
666
666
  });
667
- if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
667
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
668
668
  this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
669
669
  }
670
670
  return { issueKey, runType };
@@ -673,7 +673,7 @@ export class PatchRelayService {
673
673
  if (runType === "queue_repair") {
674
674
  const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
675
675
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
676
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
676
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
677
677
  projectId: issue.projectId,
678
678
  linearIssueId: issue.linearIssueId,
679
679
  eventType: "merge_steward_incident",
@@ -688,7 +688,7 @@ export class PatchRelayService {
688
688
  }
689
689
  if (runType === "ci_repair") {
690
690
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
691
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
691
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
692
692
  projectId: issue.projectId,
693
693
  linearIssueId: issue.linearIssueId,
694
694
  eventType: "settled_red_ci",
@@ -701,7 +701,7 @@ export class PatchRelayService {
701
701
  return;
702
702
  }
703
703
  if (runType === "review_fix" || runType === "branch_upkeep") {
704
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
704
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
705
705
  projectId: issue.projectId,
706
706
  linearIssueId: issue.linearIssueId,
707
707
  eventType: "review_changes_requested",
@@ -716,7 +716,7 @@ export class PatchRelayService {
716
716
  });
717
717
  return;
718
718
  }
719
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
719
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
720
720
  projectId: issue.projectId,
721
721
  linearIssueId: issue.linearIssueId,
722
722
  eventType: "delegated",
@@ -733,7 +733,7 @@ export class PatchRelayService {
733
733
  stores: {
734
734
  webhookEvents: {
735
735
  insertWebhookEvent: (p) => {
736
- const r = this.db.insertFullWebhookEvent(p);
736
+ const r = this.db.webhookEvents.insertFullWebhookEvent(p);
737
737
  return { id: r.id, dedupeStatus: r.dedupeStatus };
738
738
  },
739
739
  },
@@ -0,0 +1,69 @@
1
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
2
+ import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
+ import { deriveIssueStatusNote } from "./status-note.js";
4
+ import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
+ export function isResolvedLinearState(stateType, stateName) {
6
+ return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
7
+ }
8
+ export function buildTrackedIssueRecord(params) {
9
+ const unresolvedBlockedBy = params.blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
10
+ const failureContext = parseGitHubFailureContext(params.issue.lastGitHubFailureContextJson);
11
+ const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
12
+ const waitingReason = derivePatchRelayWaitingReason({
13
+ ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
14
+ blockedByKeys,
15
+ factoryState: params.issue.factoryState,
16
+ pendingRunType: params.issue.pendingRunType,
17
+ prNumber: params.issue.prNumber,
18
+ prHeadSha: params.issue.prHeadSha,
19
+ prReviewState: params.issue.prReviewState,
20
+ prCheckStatus: params.issue.prCheckStatus,
21
+ lastBlockingReviewHeadSha: params.issue.lastBlockingReviewHeadSha,
22
+ latestFailureCheckName: params.issue.lastGitHubFailureCheckName,
23
+ });
24
+ const statusNote = deriveIssueStatusNote({
25
+ issue: params.issue,
26
+ sessionSummary: params.session?.summaryText,
27
+ latestRun: params.latestRun,
28
+ latestEvent: params.latestEvent,
29
+ failureSummary: failureContext?.summary,
30
+ blockedByKeys,
31
+ waitingReason,
32
+ });
33
+ return {
34
+ id: params.issue.id,
35
+ projectId: params.issue.projectId,
36
+ linearIssueId: params.issue.linearIssueId,
37
+ ...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
38
+ ...(params.issue.title ? { title: params.issue.title } : {}),
39
+ ...(params.issue.url ? { issueUrl: params.issue.url } : {}),
40
+ ...(statusNote ? { statusNote } : {}),
41
+ ...(params.issue.currentLinearState ? { currentLinearState: params.issue.currentLinearState } : {}),
42
+ ...(params.session?.sessionState ? { sessionState: params.session.sessionState } : {}),
43
+ factoryState: params.issue.factoryState,
44
+ blockedByCount: unresolvedBlockedBy.length,
45
+ blockedByKeys,
46
+ readyForExecution: isIssueSessionReadyForExecution({
47
+ sessionState: params.session?.sessionState,
48
+ factoryState: params.issue.factoryState,
49
+ activeRunId: params.issue.activeRunId,
50
+ blockedByCount: unresolvedBlockedBy.length,
51
+ hasPendingWake: params.hasPendingWake,
52
+ hasLegacyPendingRun: params.issue.pendingRunType !== undefined,
53
+ ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
54
+ ...(params.issue.prState ? { prState: params.issue.prState } : {}),
55
+ ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
56
+ ...(params.issue.prCheckStatus ? { prCheckStatus: params.issue.prCheckStatus } : {}),
57
+ ...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
58
+ }),
59
+ ...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
60
+ ...(params.issue.lastGitHubFailureHeadSha ? { latestFailureHeadSha: params.issue.lastGitHubFailureHeadSha } : {}),
61
+ ...(params.issue.lastGitHubFailureCheckName ? { latestFailureCheckName: params.issue.lastGitHubFailureCheckName } : {}),
62
+ ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
63
+ ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
64
+ ...(waitingReason ? { waitingReason } : {}),
65
+ ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
66
+ ...(params.issue.agentSessionId ? { activeAgentSessionId: params.issue.agentSessionId } : {}),
67
+ updatedAt: params.issue.updatedAt,
68
+ };
69
+ }