patchrelay 0.68.0 → 0.68.2

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.
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
2
+ import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
3
+ export function isFailingCheckStatus(status) {
4
+ return status === "failed" || status === "failure";
5
+ }
6
+ export function isReviewDecisionApproved(value) {
7
+ return value?.trim().toUpperCase() === "APPROVED";
8
+ }
9
+ export function isReviewDecisionChangesRequested(value) {
10
+ return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
11
+ }
12
+ export function isReviewDecisionReviewRequired(value) {
13
+ return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
14
+ }
15
+ export function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
16
+ const promptContext = [
17
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
18
+ `This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
19
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
20
+ ].join(" ");
21
+ return {
22
+ branchUpkeepRequired: true,
23
+ reviewFixMode: "branch_upkeep",
24
+ wakeReason: "branch_upkeep",
25
+ promptContext,
26
+ ...(mergeStateStatus ? { mergeStateStatus } : {}),
27
+ ...(headSha ? { failingHeadSha: headSha } : {}),
28
+ baseBranch,
29
+ };
30
+ }
31
+ export function hasCompletedReviewQuillVerdict(entries) {
32
+ return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
33
+ && entry.name === "review-quill/verdict"
34
+ && entry.status === "COMPLETED");
35
+ }
36
+ export function getGateCheckNames(project) {
37
+ const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
38
+ return configured.length > 0 ? configured : ["verify"];
39
+ }
40
+ /**
41
+ * A repair attempt is "duplicate" when we have already tried to repair the
42
+ * exact same failure (same signature, same head SHA) AND no newer failure
43
+ * has been observed since that attempt was recorded. For queue evictions
44
+ * the PR head doesn't advance between attempts, so we additionally compare
45
+ * the timestamps: a fresh incident after `main` advances looks identical
46
+ * to a stale one without the timestamp check.
47
+ */
48
+ export function isDuplicateRepairAttempt(issue, context) {
49
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
50
+ const headSha = typeof context?.failureHeadSha === "string"
51
+ ? context.failureHeadSha
52
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
53
+ if (!signature)
54
+ return false;
55
+ if (issue.lastAttemptedFailureSignature !== signature)
56
+ return false;
57
+ if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
58
+ return false;
59
+ if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
60
+ && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
61
+ return false;
62
+ }
63
+ return true;
64
+ }
65
+ export function buildFailureContext(issue) {
66
+ const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
67
+ const queueRepairContext = issue.lastQueueIncidentJson
68
+ ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
69
+ : undefined;
70
+ if (!queueRepairContext
71
+ && !issue.lastGitHubFailureSource
72
+ && !issue.lastGitHubFailureHeadSha
73
+ && !issue.lastGitHubFailureSignature
74
+ && !issue.lastGitHubFailureCheckName
75
+ && !issue.lastGitHubFailureCheckUrl
76
+ && !storedFailureContext) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
81
+ ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
82
+ ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
83
+ ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
84
+ ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
85
+ ...(storedFailureContext ? storedFailureContext : {}),
86
+ ...(queueRepairContext ? queueRepairContext : {}),
87
+ };
88
+ }
89
+ export function hasFailureProvenance(issue) {
90
+ return Boolean(issue.lastGitHubFailureSource
91
+ || issue.lastGitHubFailureHeadSha
92
+ || issue.lastGitHubFailureSignature
93
+ || issue.lastGitHubFailureCheckName
94
+ || issue.lastGitHubFailureCheckUrl
95
+ || issue.lastGitHubFailureContextJson
96
+ || issue.lastGitHubFailureAt
97
+ || issue.lastQueueIncidentJson
98
+ || issue.lastAttemptedFailureHeadSha
99
+ || issue.lastAttemptedFailureSignature);
100
+ }
@@ -1,121 +1,36 @@
1
+ import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionChangesRequested, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
1
2
  import { isMainRepairIssue } from "./main-repair.js";
2
3
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
- import { parseGitHubFailureContext } from "./github-failure-context.js";
4
4
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
5
5
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
6
- import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
7
6
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
8
7
  import { getReviewFixBudget } from "./run-budgets.js";
9
8
  import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
10
9
  import { execCommand } from "./utils.js";
11
- function isFailingCheckStatus(status) {
12
- return status === "failed" || status === "failure";
13
- }
14
- function isReviewDecisionApproved(value) {
15
- return value?.trim().toUpperCase() === "APPROVED";
16
- }
17
- function isReviewDecisionChangesRequested(value) {
18
- return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
19
- }
20
- function isReviewDecisionReviewRequired(value) {
21
- return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
22
- }
23
- function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
24
- const promptContext = [
25
- `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
26
- `This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
27
- "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
28
- ].join(" ");
29
- return {
30
- branchUpkeepRequired: true,
31
- reviewFixMode: "branch_upkeep",
32
- wakeReason: "branch_upkeep",
33
- promptContext,
34
- ...(mergeStateStatus ? { mergeStateStatus } : {}),
35
- ...(headSha ? { failingHeadSha: headSha } : {}),
36
- baseBranch,
37
- };
38
- }
39
- function hasCompletedReviewQuillVerdict(entries) {
40
- return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
41
- && entry.name === "review-quill/verdict"
42
- && entry.status === "COMPLETED");
43
- }
44
- function getGateCheckNames(project) {
45
- const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
46
- return configured.length > 0 ? configured : ["verify"];
47
- }
48
- function isDuplicateRepairAttempt(issue, context) {
49
- const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
50
- const headSha = typeof context?.failureHeadSha === "string"
51
- ? context.failureHeadSha
52
- : typeof context?.headSha === "string" ? context.headSha : undefined;
53
- if (!signature)
54
- return false;
55
- if (issue.lastAttemptedFailureSignature !== signature)
56
- return false;
57
- if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
58
- return false;
59
- // A signature+headSha match alone isn't enough: for queue evictions the PR head
60
- // doesn't advance (we haven't pushed) and the steward's check name is constant,
61
- // so a fresh incident after main advances looks identical. Treat the attempt as
62
- // stale if a newer failure has been observed since it was recorded.
63
- if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
64
- && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
65
- return false;
66
- }
67
- return true;
68
- }
69
- function buildFailureContext(issue) {
70
- const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
71
- const queueRepairContext = issue.lastQueueIncidentJson
72
- ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
73
- : undefined;
74
- if (!queueRepairContext
75
- && !issue.lastGitHubFailureSource
76
- && !issue.lastGitHubFailureHeadSha
77
- && !issue.lastGitHubFailureSignature
78
- && !issue.lastGitHubFailureCheckName
79
- && !issue.lastGitHubFailureCheckUrl
80
- && !storedFailureContext) {
81
- return undefined;
82
- }
83
- return {
84
- ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
85
- ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
86
- ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
87
- ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
88
- ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
89
- ...(storedFailureContext ? storedFailureContext : {}),
90
- ...(queueRepairContext ? queueRepairContext : {}),
91
- };
92
- }
93
- function hasFailureProvenance(issue) {
94
- return Boolean(issue.lastGitHubFailureSource
95
- || issue.lastGitHubFailureHeadSha
96
- || issue.lastGitHubFailureSignature
97
- || issue.lastGitHubFailureCheckName
98
- || issue.lastGitHubFailureCheckUrl
99
- || issue.lastGitHubFailureContextJson
100
- || issue.lastGitHubFailureAt
101
- || issue.lastQueueIncidentJson
102
- || issue.lastAttemptedFailureHeadSha
103
- || issue.lastAttemptedFailureSignature);
104
- }
105
10
  export class IdleIssueReconciler {
106
11
  db;
107
12
  config;
108
- deps;
13
+ wakeDispatcher;
109
14
  logger;
110
15
  feed;
111
- constructor(db, config, deps, logger, feed) {
16
+ constructor(db, config, wakeDispatcher, logger, feed) {
112
17
  this.db = db;
113
18
  this.config = config;
114
- this.deps = deps;
19
+ this.wakeDispatcher = wakeDispatcher;
115
20
  this.logger = logger;
116
21
  this.feed = feed;
117
22
  }
118
23
  async reconcile() {
24
+ // Wrap the entire reconcile pass in a dispatcher tick. Every
25
+ // dispatchIfWakePending / recordEventAndDispatch call inside the
26
+ // callback automatically shares one dedupe Set, so a single pass
27
+ // produces at most one enqueue per issue even when several sub-
28
+ // passes detect the same wake. SerialWorkQueue would dedupe anyway,
29
+ // but keeping the call log clean makes orchestrator behaviour
30
+ // easier to inspect from tests and the operator feed.
31
+ return this.wakeDispatcher.withTick(() => this.reconcileBody());
32
+ }
33
+ async reconcileBody() {
119
34
  for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
120
35
  if (issue.prState === "merged") {
121
36
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
@@ -161,15 +76,10 @@ export class IdleIssueReconciler {
161
76
  continue;
162
77
  const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
163
78
  if (unresolved === 0) {
164
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
165
- projectId: issue.projectId,
166
- linearIssueId: issue.linearIssueId,
79
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
167
80
  eventType: "delegated",
168
81
  dedupeKey: `delegated:${issue.linearIssueId}`,
169
82
  });
170
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
171
- this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
172
- }
173
83
  }
174
84
  }
175
85
  const now = Date.now();
@@ -187,9 +97,19 @@ export class IdleIssueReconciler {
187
97
  queueSettledOrchestrationIssue({
188
98
  db: this.db,
189
99
  issue,
190
- enqueueIssue: this.deps.enqueueIssue,
100
+ wakeDispatcher: this.wakeDispatcher,
191
101
  });
192
102
  }
103
+ // Safety net: re-enqueue any idle delegated issue that still has
104
+ // unprocessed session events. Until this pass existed, a single
105
+ // dropped enqueueIssue (lease race, in-memory queue lost across
106
+ // restart) left review_fix / ci_repair / queue_repair wakes stuck
107
+ // for hours until an external event re-poked the issue. The
108
+ // surrounding withTick scope ensures the call log shows at most one
109
+ // enqueue per issue per pass even when earlier passes also queued.
110
+ for (const issue of this.db.issues.listIdleIssuesWithPendingWake()) {
111
+ this.wakeDispatcher.dispatchIfWakePending(issue.projectId, issue.linearIssueId);
112
+ }
193
113
  }
194
114
  shouldProbeTerminalIssueFromGitHub(issue) {
195
115
  if (issue.prNumber === undefined)
@@ -232,7 +152,7 @@ export class IdleIssueReconciler {
232
152
  : {}),
233
153
  });
234
154
  if (options?.pendingRunType) {
235
- this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
155
+ this.recordWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
236
156
  }
237
157
  this.feed?.publish({
238
158
  level: "info",
@@ -243,11 +163,11 @@ export class IdleIssueReconciler {
243
163
  status: "reconciled",
244
164
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
245
165
  });
246
- if (options?.pendingRunType && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
247
- this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
248
- }
166
+ // The dispatcher's recordEventAndDispatch in recordWakeEvent already
167
+ // handles the enqueue when no run is in flight, so no extra poke
168
+ // is needed here.
249
169
  }
250
- appendWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
170
+ recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
251
171
  let eventType;
252
172
  let dedupeKey;
253
173
  if (runType === "queue_repair") {
@@ -266,9 +186,7 @@ export class IdleIssueReconciler {
266
186
  eventType = "delegated";
267
187
  dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
268
188
  }
269
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
270
- projectId: issue.projectId,
271
- linearIssueId: issue.linearIssueId,
189
+ this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
272
190
  eventType,
273
191
  ...(context ? { eventJson: JSON.stringify(context) } : {}),
274
192
  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",