patchrelay 0.68.0 → 0.68.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.68.0",
4
- "commit": "3a86b2f7100f",
5
- "builtAt": "2026-05-13T20:52:25.195Z"
3
+ "version": "0.68.1",
4
+ "commit": "1cc1884e5fe9",
5
+ "builtAt": "2026-05-15T07:24:40.081Z"
6
6
  }
@@ -416,6 +416,25 @@ export class IssueStore {
416
416
  .all();
417
417
  return rows.map(mapIssueRow);
418
418
  }
419
+ // Safety net for orphaned wakes: any delegated, non-terminal issue
420
+ // with at least one unprocessed session event but no active run.
421
+ // The orchestrator's enqueueIssue is the only path that drains these
422
+ // events, and a prior enqueueIssue call can be silently lost (worker
423
+ // race, lease contention, in-memory queue cleared by service restart).
424
+ // The idle reconciler iterates this set and re-enqueues each one.
425
+ listIdleIssuesWithPendingWake() {
426
+ const rows = this.connection
427
+ .prepare(`SELECT DISTINCT i.* FROM issues i
428
+ INNER JOIN issue_session_events e
429
+ ON e.project_id = i.project_id
430
+ AND e.linear_issue_id = i.linear_issue_id
431
+ WHERE e.processed_at IS NULL
432
+ AND i.active_run_id IS NULL
433
+ AND i.delegated_to_patchrelay = 1
434
+ AND i.factory_state NOT IN ('done', 'escalated', 'failed', 'awaiting_input')`)
435
+ .all();
436
+ return rows.map(mapIssueRow);
437
+ }
419
438
  listBlockedDelegatedIssues() {
420
439
  const rows = this.connection
421
440
  .prepare(`SELECT DISTINCT i.* FROM issues i
package/dist/db.js CHANGED
@@ -182,6 +182,14 @@ export class PatchRelayDatabase {
182
182
  listIdleNonTerminalIssues() {
183
183
  return this.issues.listIdleNonTerminalIssues();
184
184
  }
185
+ /**
186
+ * Idle delegated issues that still have unprocessed session events.
187
+ * The idle reconciler re-enqueues these to recover from a silently
188
+ * dropped enqueueIssue (lease race, in-memory queue cleared at restart).
189
+ */
190
+ listIdleIssuesWithPendingWake() {
191
+ return this.issues.listIdleIssuesWithPendingWake();
192
+ }
185
193
  /**
186
194
  * Issues in delegated state with dependencies but no pending/active run.
187
195
  * Candidates for unblocking when their blockers complete.
@@ -1,12 +1,12 @@
1
1
  export class GitHubPrCommentHandler {
2
2
  db;
3
- enqueueIssue;
3
+ wakeDispatcher;
4
4
  logger;
5
5
  codex;
6
6
  feed;
7
- constructor(db, enqueueIssue, logger, codex, feed) {
7
+ constructor(db, wakeDispatcher, logger, codex, feed) {
8
8
  this.db = db;
9
- this.enqueueIssue = enqueueIssue;
9
+ this.wakeDispatcher = wakeDispatcher;
10
10
  this.logger = logger;
11
11
  this.codex = codex;
12
12
  this.feed = feed;
@@ -61,14 +61,9 @@ export class GitHubPrCommentHandler {
61
61
  }
62
62
  }
63
63
  }
64
- this.db.issueSessions.appendIssueSessionEvent({
65
- projectId: issue.projectId,
66
- linearIssueId: issue.linearIssueId,
64
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
67
65
  eventType: "followup_comment",
68
66
  eventJson: JSON.stringify({ body, author }),
69
67
  });
70
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
71
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
72
- }
73
68
  }
74
69
  }
@@ -10,11 +10,11 @@ import { maybeEnqueueGitHubReactiveRun } from "./github-webhook-reactive-run.js"
10
10
  import { maybeRunSequenceBackstop } from "./github-webhook-sequence-backstop.js";
11
11
  import { maybeFanChildRebaseWakes } from "./github-webhook-stack-coordination.js";
12
12
  import { handleGitHubTerminalPrEvent } from "./github-webhook-terminal-handler.js";
13
+ import { WakeDispatcher } from "./wake-dispatcher.js";
13
14
  export class GitHubWebhookHandler {
14
15
  config;
15
16
  db;
16
17
  linearProvider;
17
- enqueueIssue;
18
18
  logger;
19
19
  codex;
20
20
  feed;
@@ -22,18 +22,23 @@ export class GitHubWebhookHandler {
22
22
  ciSnapshotResolver;
23
23
  fetchImpl;
24
24
  prCommentHandler;
25
- constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
25
+ wakeDispatcher;
26
+ constructor(config, db, linearProvider, wakeDispatcherOrEnqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
26
27
  this.config = config;
27
28
  this.db = db;
28
29
  this.linearProvider = linearProvider;
29
- this.enqueueIssue = enqueueIssue;
30
30
  this.logger = logger;
31
31
  this.codex = codex;
32
32
  this.feed = feed;
33
33
  this.failureContextResolver = failureContextResolver;
34
34
  this.ciSnapshotResolver = ciSnapshotResolver;
35
35
  this.fetchImpl = fetchImpl;
36
- this.prCommentHandler = new GitHubPrCommentHandler(db, enqueueIssue, logger, codex, feed);
36
+ // GitHub webhook handlers never release leases either — see
37
+ // WebhookHandler for the same rationale.
38
+ this.wakeDispatcher = wakeDispatcherOrEnqueueIssue instanceof WakeDispatcher
39
+ ? wakeDispatcherOrEnqueueIssue
40
+ : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
41
+ this.prCommentHandler = new GitHubPrCommentHandler(db, this.wakeDispatcher, logger, codex, feed);
37
42
  }
38
43
  async acceptGitHubWebhook(params) {
39
44
  if (this.db.webhookEvents.isWebhookDuplicate(params.deliveryId)) {
@@ -125,7 +130,7 @@ export class GitHubWebhookHandler {
125
130
  db: this.db,
126
131
  logger: this.logger,
127
132
  feed: this.feed,
128
- enqueueIssue: this.enqueueIssue,
133
+ wakeDispatcher: this.wakeDispatcher,
129
134
  issue: freshIssue,
130
135
  event,
131
136
  project,
@@ -152,7 +157,7 @@ export class GitHubWebhookHandler {
152
157
  db: this.db,
153
158
  logger: this.logger,
154
159
  ...(this.feed ? { feed: this.feed } : {}),
155
- enqueueIssue: this.enqueueIssue,
160
+ wakeDispatcher: this.wakeDispatcher,
156
161
  event,
157
162
  });
158
163
  }
@@ -161,7 +166,7 @@ export class GitHubWebhookHandler {
161
166
  config: this.config,
162
167
  db: this.db,
163
168
  linearProvider: this.linearProvider,
164
- enqueueIssue: this.enqueueIssue,
169
+ wakeDispatcher: this.wakeDispatcher,
165
170
  logger: this.logger,
166
171
  codex: this.codex,
167
172
  feed: this.feed,
@@ -4,7 +4,7 @@ import { isIssueTerminal } from "./pr-state.js";
4
4
  import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
5
5
  import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
6
6
  export async function maybeEnqueueGitHubReactiveRun(params) {
7
- const { issue, event, project, logger, feed, enqueueIssue, db, fetchImpl, failureContextResolver } = params;
7
+ const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
8
8
  if (isIssueTerminal(issue))
9
9
  return;
10
10
  if (!issue.delegatedToPatchRelay) {
@@ -27,7 +27,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
27
27
  db,
28
28
  logger,
29
29
  feed,
30
- enqueueIssue,
30
+ wakeDispatcher,
31
31
  issue,
32
32
  event,
33
33
  project,
@@ -40,7 +40,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
40
40
  db,
41
41
  logger,
42
42
  feed,
43
- enqueueIssue,
43
+ wakeDispatcher,
44
44
  issue,
45
45
  event,
46
46
  fetchImpl,
@@ -48,7 +48,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
48
48
  }
49
49
  }
50
50
  async function handleCheckFailedEvent(params) {
51
- const { db, logger, feed, enqueueIssue, issue, event, project, failureContextResolver } = params;
51
+ const { db, logger, feed, wakeDispatcher, issue, event, project, failureContextResolver } = params;
52
52
  // Plan §4.3: while In Deploy (`awaiting_queue`), branch CI is metadata
53
53
  // only — the lander owns admission, and its spec CI on the integration
54
54
  // tree is the gate. Queue eviction failures still flow through (they're
@@ -71,20 +71,14 @@ async function handleCheckFailedEvent(params) {
71
71
  if (hasDuplicatePendingReactiveRun(db, feed, issue, "queue_repair", failureContext)) {
72
72
  return;
73
73
  }
74
- const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
75
- db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
76
- projectId: issue.projectId,
77
- linearIssueId: issue.linearIssueId,
74
+ const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
78
75
  eventType: "merge_steward_incident",
79
76
  eventJson: JSON.stringify({
80
77
  ...queueRepairContext,
81
78
  ...failureContext,
82
79
  }),
83
- dedupeKey: failureContext.failureSignature,
80
+ ...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
84
81
  });
85
- const queuedRunType = hadPendingWake
86
- ? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
87
- : enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
88
82
  logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
89
83
  feed?.publish({
90
84
  level: "warn",
@@ -120,7 +114,6 @@ async function handleCheckFailedEvent(params) {
120
114
  if (hasDuplicatePendingReactiveRun(db, feed, issue, "ci_repair", failureContext)) {
121
115
  return;
122
116
  }
123
- const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
124
117
  const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
125
118
  db.issues.upsertIssue({
126
119
  projectId: issue.projectId,
@@ -134,20 +127,15 @@ async function handleCheckFailedEvent(params) {
134
127
  lastGitHubFailureAt: new Date().toISOString(),
135
128
  lastQueueIncidentJson: null,
136
129
  });
137
- db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
138
- projectId: issue.projectId,
139
- linearIssueId: issue.linearIssueId,
130
+ const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
140
131
  eventType: "settled_red_ci",
141
132
  eventJson: JSON.stringify({
142
133
  ...failureContext,
143
134
  checkClass: resolveGitHubCheckClass(failureContext.checkName ?? event.checkName, project),
144
135
  ...(snapshot ? { ciSnapshot: snapshot } : {}),
145
136
  }),
146
- dedupeKey: failureContext.failureSignature,
137
+ ...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
147
138
  });
148
- const queuedRunType = hadPendingWake
149
- ? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
150
- : enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
151
139
  logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
152
140
  feed?.publish({
153
141
  level: "warn",
@@ -161,8 +149,7 @@ async function handleCheckFailedEvent(params) {
161
149
  });
162
150
  }
163
151
  async function handleRequestedChangesEvent(params) {
164
- const { db, logger, feed, enqueueIssue, issue, event, fetchImpl } = params;
165
- const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
152
+ const { logger, feed, wakeDispatcher, issue, event, fetchImpl } = params;
166
153
  const reviewComments = await fetchReviewCommentsForEvent(event, fetchImpl).catch((error) => {
167
154
  logger.warn({
168
155
  issueKey: issue.issueKey,
@@ -172,9 +159,7 @@ async function handleRequestedChangesEvent(params) {
172
159
  }, "Failed to fetch inline review comments for requested-changes event");
173
160
  return undefined;
174
161
  });
175
- db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
176
- projectId: issue.projectId,
177
- linearIssueId: issue.linearIssueId,
162
+ const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
178
163
  eventType: "review_changes_requested",
179
164
  eventJson: JSON.stringify({
180
165
  reviewBody: event.reviewBody,
@@ -190,11 +175,6 @@ async function handleRequestedChangesEvent(params) {
190
175
  event.reviewerName ?? "unknown-reviewer",
191
176
  ].join("::"),
192
177
  });
193
- const queuedRunType = hadPendingWake
194
- ? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
195
- : issue.activeRunId === undefined
196
- ? enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId)
197
- : db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType;
198
178
  logger.info({
199
179
  issueKey: issue.issueKey,
200
180
  reviewerName: event.reviewerName,
@@ -254,14 +234,6 @@ function hasDuplicatePendingReactiveRun(db, feed, issue, runType, failureContext
254
234
  }
255
235
  return false;
256
236
  }
257
- function enqueuePendingSessionWake(db, enqueueIssue, projectId, issueId) {
258
- const wake = db.issueSessions.peekIssueSessionWake(projectId, issueId);
259
- if (!wake) {
260
- return undefined;
261
- }
262
- enqueueIssue(projectId, issueId);
263
- return wake.runType;
264
- }
265
237
  async function fetchReviewCommentsForEvent(event, fetchImpl) {
266
238
  if (event.triggerEvent !== "review_changes_requested") {
267
239
  return undefined;
@@ -4,7 +4,7 @@
4
4
  // matching child and enqueues a `branch_upkeep` run to rebase the
5
5
  // child onto the new parent head.
6
6
  export function maybeFanChildRebaseWakes(params) {
7
- const { db, logger, feed, enqueueIssue, event } = params;
7
+ const { db, logger, feed, wakeDispatcher, event } = params;
8
8
  if (event.triggerEvent !== "pr_synchronize")
9
9
  return;
10
10
  if (!event.branchName)
@@ -24,7 +24,10 @@ export function maybeFanChildRebaseWakes(params) {
24
24
  linearIssueId: child.linearIssueId,
25
25
  pendingRunType: "branch_upkeep",
26
26
  });
27
- enqueueIssue(child.projectId, child.linearIssueId);
27
+ // The pending_run_type field above isn't an event, so we still need
28
+ // an explicit dispatch call. dispatchIfWakePending will pick up the
29
+ // wake derived from the legacy pendingRunType column.
30
+ wakeDispatcher.dispatchIfWakePending(child.projectId, child.linearIssueId);
28
31
  logger.info({
29
32
  parentBranch: event.branchName,
30
33
  parentHeadSha: event.headSha,
@@ -3,7 +3,7 @@ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
3
  import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
4
  import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
5
5
  export async function handleGitHubTerminalPrEvent(params) {
6
- const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
6
+ const { db, linearProvider, wakeDispatcher, logger, codex, issue, event, config } = params;
7
7
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
8
8
  db.issueSessions.appendIssueSessionEvent({
9
9
  projectId: issue.projectId,
@@ -56,22 +56,17 @@ export async function handleGitHubTerminalPrEvent(params) {
56
56
  db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
57
57
  const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
58
58
  if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
59
- db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
60
- projectId: issue.projectId,
61
- linearIssueId: issue.linearIssueId,
59
+ wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
62
60
  eventType: "delegated",
63
61
  dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
64
62
  });
65
- if (db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
66
- enqueueIssue(issue.projectId, issue.linearIssueId);
67
- }
68
63
  }
69
64
  if (event.triggerEvent === "pr_merged") {
70
65
  wakeOrchestrationParentsForChildEvent({
71
66
  db,
72
67
  child: updatedIssue,
73
68
  eventType: "child_delivered",
74
- enqueueIssue,
69
+ wakeDispatcher,
75
70
  });
76
71
  await completeLinearIssueAfterMerge(params, updatedIssue);
77
72
  }
@@ -105,17 +105,27 @@ function hasFailureProvenance(issue) {
105
105
  export class IdleIssueReconciler {
106
106
  db;
107
107
  config;
108
- deps;
108
+ wakeDispatcher;
109
109
  logger;
110
110
  feed;
111
- constructor(db, config, deps, logger, feed) {
111
+ constructor(db, config, wakeDispatcher, logger, feed) {
112
112
  this.db = db;
113
113
  this.config = config;
114
- this.deps = deps;
114
+ this.wakeDispatcher = wakeDispatcher;
115
115
  this.logger = logger;
116
116
  this.feed = feed;
117
117
  }
118
118
  async reconcile() {
119
+ // Wrap the entire reconcile pass in a dispatcher tick. Every
120
+ // dispatchIfWakePending / recordEventAndDispatch call inside the
121
+ // callback automatically shares one dedupe Set, so a single pass
122
+ // produces at most one enqueue per issue even when several sub-
123
+ // passes detect the same wake. SerialWorkQueue would dedupe anyway,
124
+ // but keeping the call log clean makes orchestrator behaviour
125
+ // easier to inspect from tests and the operator feed.
126
+ return this.wakeDispatcher.withTick(() => this.reconcileBody());
127
+ }
128
+ async reconcileBody() {
119
129
  for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
120
130
  if (issue.prState === "merged") {
121
131
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
@@ -161,15 +171,10 @@ export class IdleIssueReconciler {
161
171
  continue;
162
172
  const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
163
173
  if (unresolved === 0) {
164
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
165
- projectId: issue.projectId,
166
- linearIssueId: issue.linearIssueId,
174
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
167
175
  eventType: "delegated",
168
176
  dedupeKey: `delegated:${issue.linearIssueId}`,
169
177
  });
170
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
171
- this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
172
- }
173
178
  }
174
179
  }
175
180
  const now = Date.now();
@@ -187,9 +192,19 @@ export class IdleIssueReconciler {
187
192
  queueSettledOrchestrationIssue({
188
193
  db: this.db,
189
194
  issue,
190
- enqueueIssue: this.deps.enqueueIssue,
195
+ wakeDispatcher: this.wakeDispatcher,
191
196
  });
192
197
  }
198
+ // Safety net: re-enqueue any idle delegated issue that still has
199
+ // unprocessed session events. Until this pass existed, a single
200
+ // dropped enqueueIssue (lease race, in-memory queue lost across
201
+ // restart) left review_fix / ci_repair / queue_repair wakes stuck
202
+ // for hours until an external event re-poked the issue. The
203
+ // surrounding withTick scope ensures the call log shows at most one
204
+ // enqueue per issue per pass even when earlier passes also queued.
205
+ for (const issue of this.db.issues.listIdleIssuesWithPendingWake()) {
206
+ this.wakeDispatcher.dispatchIfWakePending(issue.projectId, issue.linearIssueId);
207
+ }
193
208
  }
194
209
  shouldProbeTerminalIssueFromGitHub(issue) {
195
210
  if (issue.prNumber === undefined)
@@ -232,7 +247,7 @@ export class IdleIssueReconciler {
232
247
  : {}),
233
248
  });
234
249
  if (options?.pendingRunType) {
235
- this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
250
+ this.recordWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
236
251
  }
237
252
  this.feed?.publish({
238
253
  level: "info",
@@ -243,11 +258,11 @@ export class IdleIssueReconciler {
243
258
  status: "reconciled",
244
259
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
245
260
  });
246
- if (options?.pendingRunType && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
247
- this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
248
- }
261
+ // The dispatcher's recordEventAndDispatch in recordWakeEvent already
262
+ // handles the enqueue when no run is in flight, so no extra poke
263
+ // is needed here.
249
264
  }
250
- appendWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
265
+ recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
251
266
  let eventType;
252
267
  let dedupeKey;
253
268
  if (runType === "queue_repair") {
@@ -266,9 +281,7 @@ export class IdleIssueReconciler {
266
281
  eventType = "delegated";
267
282
  dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
268
283
  }
269
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
270
- projectId: issue.projectId,
271
- linearIssueId: issue.linearIssueId,
284
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
272
285
  eventType,
273
286
  ...(context ? { eventJson: JSON.stringify(context) } : {}),
274
287
  dedupeKey,
@@ -14,14 +14,14 @@ export class MainBranchHealthMonitor {
14
14
  db;
15
15
  config;
16
16
  linearProvider;
17
- enqueueIssue;
17
+ wakeDispatcher;
18
18
  logger;
19
19
  feed;
20
- constructor(db, config, linearProvider, enqueueIssue, logger, feed) {
20
+ constructor(db, config, linearProvider, wakeDispatcher, logger, feed) {
21
21
  this.db = db;
22
22
  this.config = config;
23
23
  this.linearProvider = linearProvider;
24
- this.enqueueIssue = enqueueIssue;
24
+ this.wakeDispatcher = wakeDispatcher;
25
25
  this.logger = logger;
26
26
  this.feed = feed;
27
27
  }
@@ -82,9 +82,7 @@ export class MainBranchHealthMonitor {
82
82
  branchName,
83
83
  factoryState: "delegated",
84
84
  });
85
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, issue.linearIssueId, {
86
- projectId,
87
- linearIssueId: issue.linearIssueId,
85
+ this.wakeDispatcher.recordEventAndDispatch(projectId, issue.linearIssueId, {
88
86
  eventType: "delegated",
89
87
  eventJson: JSON.stringify({
90
88
  runType: "main_repair",
@@ -96,9 +94,6 @@ export class MainBranchHealthMonitor {
96
94
  }),
97
95
  dedupeKey: `main_repair:${projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
98
96
  });
99
- if (this.db.issueSessions.peekIssueSessionWake(projectId, issue.linearIssueId)) {
100
- this.enqueueIssue(projectId, issue.linearIssueId);
101
- }
102
97
  this.feed?.publish({
103
98
  level: "warn",
104
99
  kind: "github",
@@ -153,9 +148,7 @@ export class MainBranchHealthMonitor {
153
148
  pendingRunContextJson: null,
154
149
  activeRunId: null,
155
150
  });
156
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
157
- projectId: issue.projectId,
158
- linearIssueId: issue.linearIssueId,
151
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
159
152
  eventType: "delegated",
160
153
  eventJson: JSON.stringify({
161
154
  runType: "main_repair",
@@ -167,9 +160,6 @@ export class MainBranchHealthMonitor {
167
160
  }),
168
161
  dedupeKey: `main_repair:${issue.projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
169
162
  });
170
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
171
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
172
- }
173
163
  }
174
164
  async resolveRecoveredMainRepair(issue) {
175
165
  if (issue.activeRunId !== undefined)
@@ -89,7 +89,6 @@ export async function handleNoPrCompletionCheck(params) {
89
89
  summary: "No PR found; continuing automatically",
90
90
  detail: completionCheck.summary,
91
91
  activity: buildCompletionCheckActivity("continue"),
92
- enqueue: true,
93
92
  });
94
93
  return;
95
94
  }
@@ -166,7 +165,6 @@ export async function handleNoPrCompletionCheck(params) {
166
165
  summary: "No PR found; continuing automatically to finish publication",
167
166
  detail: params.publishedOutcomeError,
168
167
  activity: buildCompletionCheckActivity("continue"),
169
- enqueue: true,
170
168
  });
171
169
  return;
172
170
  }
@@ -211,7 +209,6 @@ export async function handleNoPrCompletionCheck(params) {
211
209
  summary: "No repair PR found; continuing automatically",
212
210
  detail: "Main repair cannot close until PatchRelay publishes a repair PR or main recovers externally.",
213
211
  activity: buildCompletionCheckActivity("continue"),
214
- enqueue: true,
215
212
  });
216
213
  return;
217
214
  }
@@ -267,6 +264,7 @@ export async function handleNoPrCompletionCheck(params) {
267
264
  db: params.db,
268
265
  child: doneIssue,
269
266
  eventType: "child_delivered",
267
+ wakeDispatcher: params.wakeDispatcher,
270
268
  });
271
269
  return;
272
270
  }
@@ -37,9 +37,7 @@ export function queueSettledOrchestrationIssue(params) {
37
37
  linearIssueId: params.issue.linearIssueId,
38
38
  orchestrationSettleUntil: null,
39
39
  });
40
- params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.issue.projectId, params.issue.linearIssueId, {
41
- projectId: params.issue.projectId,
42
- linearIssueId: params.issue.linearIssueId,
40
+ const dispatched = params.wakeDispatcher.recordEventAndDispatch(params.issue.projectId, params.issue.linearIssueId, {
43
41
  eventType: "delegated",
44
42
  eventJson: JSON.stringify({
45
43
  ...(params.promptContext
@@ -48,11 +46,7 @@ export function queueSettledOrchestrationIssue(params) {
48
46
  }),
49
47
  dedupeKey: `delegated:orchestration_settle:${params.issue.linearIssueId}`,
50
48
  });
51
- if (params.db.issueSessions.peekIssueSessionWake(params.issue.projectId, params.issue.linearIssueId)) {
52
- params.enqueueIssue?.(params.issue.projectId, params.issue.linearIssueId);
53
- return true;
54
- }
55
- return false;
49
+ return dispatched !== undefined;
56
50
  }
57
51
  export function wakeOrchestrationParentsForChildEvent(params) {
58
52
  const parentIds = [];
@@ -71,9 +65,7 @@ export function wakeOrchestrationParentsForChildEvent(params) {
71
65
  parentIds.push(parent.linearIssueId);
72
66
  continue;
73
67
  }
74
- params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(parent.projectId, parent.linearIssueId, {
75
- projectId: parent.projectId,
76
- linearIssueId: parent.linearIssueId,
68
+ params.wakeDispatcher.recordEventAndDispatch(parent.projectId, parent.linearIssueId, {
77
69
  eventType: params.eventType,
78
70
  eventJson: JSON.stringify({
79
71
  childIssueId: params.child.linearIssueId,
@@ -87,9 +79,6 @@ export function wakeOrchestrationParentsForChildEvent(params) {
87
79
  }),
88
80
  dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.changeKind ?? params.child.prState ?? "no-pr"}`,
89
81
  });
90
- if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
91
- params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
92
- }
93
82
  parentIds.push(parent.linearIssueId);
94
83
  }
95
84
  return unique(parentIds);
@@ -149,17 +149,12 @@ export class QueueHealthMonitor {
149
149
  lastAttemptedFailureHeadSha: headRefOid,
150
150
  lastAttemptedFailureSignature: signature,
151
151
  });
152
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
153
- projectId: issue.projectId,
154
- linearIssueId: issue.linearIssueId,
152
+ this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
155
153
  eventType: "merge_steward_incident",
156
154
  eventJson: JSON.stringify(pendingRunContext),
157
155
  dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
158
156
  });
159
157
  this.advancer.advanceIdleIssue(issue, "repairing_queue");
160
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
161
- this.advancer.enqueueIssue(issue.projectId, issue.linearIssueId);
162
- }
163
158
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
164
159
  this.feed?.publish({
165
160
  level: "warn",