patchrelay 0.35.11 → 0.35.12

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.
Files changed (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +268 -76
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -1,7 +1,16 @@
1
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
2
  import { parseGitHubFailureContext } from "./github-failure-context.js";
3
+ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
+ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
4
6
  import { execCommand } from "./utils.js";
7
+ function isFailingCheckStatus(status) {
8
+ return status === "failed" || status === "failure";
9
+ }
10
+ function getGateCheckNames(project) {
11
+ const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
12
+ return configured.length > 0 ? configured : ["verify"];
13
+ }
5
14
  function isDuplicateRepairAttempt(issue, context) {
6
15
  const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
7
16
  const headSha = typeof context?.failureHeadSha === "string"
@@ -36,11 +45,23 @@ function buildFailureContext(issue) {
36
45
  ...(queueRepairContext ? queueRepairContext : {}),
37
46
  };
38
47
  }
48
+ function hasFailureProvenance(issue) {
49
+ return Boolean(issue.lastGitHubFailureSource
50
+ || issue.lastGitHubFailureHeadSha
51
+ || issue.lastGitHubFailureSignature
52
+ || issue.lastGitHubFailureCheckName
53
+ || issue.lastGitHubFailureCheckUrl
54
+ || issue.lastGitHubFailureContextJson
55
+ || issue.lastGitHubFailureAt
56
+ || issue.lastQueueIncidentJson
57
+ || issue.lastAttemptedFailureHeadSha
58
+ || issue.lastAttemptedFailureSignature);
59
+ }
39
60
  export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
40
61
  if (pendingRunType)
41
62
  return "patchrelay";
42
63
  if (newState === "awaiting_queue")
43
- return "merge_steward";
64
+ return "patchrelay";
44
65
  if (newState === "repairing_ci" || newState === "repairing_queue")
45
66
  return "patchrelay";
46
67
  return undefined;
@@ -64,16 +85,27 @@ export class IdleIssueReconciler {
64
85
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
65
86
  continue;
66
87
  }
67
- if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
68
- if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
88
+ if (issue.lastGitHubFailureSource === "queue_eviction") {
89
+ await this.routeFailedIssue(issue);
90
+ continue;
91
+ }
92
+ if (issue.lastGitHubFailureSource === "branch_ci") {
93
+ await this.routeFailedIssue(issue);
94
+ continue;
95
+ }
96
+ if (issue.prReviewState === "approved" && !isFailingCheckStatus(issue.prCheckStatus)) {
97
+ if (issue.prNumber) {
98
+ await this.reconcileFromGitHub(issue);
99
+ }
100
+ else if (issue.factoryState !== "awaiting_queue") {
69
101
  this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
70
102
  }
71
- else if (!issue.queueLabelApplied) {
72
- await this.deps.requestMergeQueueAdmission(issue, issue.projectId);
103
+ else if (hasFailureProvenance(issue)) {
104
+ this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
73
105
  }
74
106
  continue;
75
107
  }
76
- if (issue.prCheckStatus === "failed") {
108
+ if (isFailingCheckStatus(issue.prCheckStatus)) {
77
109
  await this.routeFailedIssue(issue);
78
110
  continue;
79
111
  }
@@ -86,12 +118,15 @@ export class IdleIssueReconciler {
86
118
  for (const issue of this.db.listBlockedDelegatedIssues()) {
87
119
  const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
88
120
  if (unresolved === 0) {
89
- this.db.upsertIssue({
121
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
90
122
  projectId: issue.projectId,
91
123
  linearIssueId: issue.linearIssueId,
92
- pendingRunType: "implementation",
124
+ eventType: "delegated",
125
+ dedupeKey: `delegated:${issue.linearIssueId}`,
93
126
  });
94
- this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
127
+ if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
128
+ this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
129
+ }
95
130
  }
96
131
  }
97
132
  }
@@ -100,18 +135,16 @@ export class IdleIssueReconciler {
100
135
  return;
101
136
  }
102
137
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
103
- const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
104
138
  this.db.upsertIssue({
105
139
  projectId: issue.projectId,
106
140
  linearIssueId: issue.linearIssueId,
107
141
  factoryState: newState,
108
- ...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
109
- ...(options?.pendingRunType
142
+ ...((options?.pendingRunType || newState === "awaiting_queue" || newState === "delegated" || newState === "done")
110
143
  ? {
111
- pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
144
+ pendingRunType: null,
145
+ pendingRunContextJson: null,
112
146
  }
113
147
  : {}),
114
- ...(resetQueueLabel ? { queueLabelApplied: false } : {}),
115
148
  ...(options?.clearFailureProvenance
116
149
  ? {
117
150
  lastGitHubFailureSource: null,
@@ -131,6 +164,9 @@ export class IdleIssueReconciler {
131
164
  if (branchOwner) {
132
165
  this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
133
166
  }
167
+ if (options?.pendingRunType) {
168
+ this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
169
+ }
134
170
  this.feed?.publish({
135
171
  level: "info",
136
172
  kind: "stage",
@@ -140,57 +176,52 @@ export class IdleIssueReconciler {
140
176
  status: "reconciled",
141
177
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
142
178
  });
143
- if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
144
- void this.deps.requestMergeQueueAdmission(issue, issue.projectId);
145
- }
146
- if (options?.pendingRunType) {
179
+ if (options?.pendingRunType && this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
147
180
  this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
148
181
  }
149
182
  }
150
- async routeFailedIssue(issue) {
151
- if (issue.lastGitHubFailureSource === "queue_eviction") {
152
- const pendingRunContext = buildFailureContext(issue);
153
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
154
- this.advanceIdleIssue(issue, "repairing_queue");
155
- }
156
- else {
157
- this.advanceIdleIssue(issue, "repairing_queue", {
158
- pendingRunType: "queue_repair",
159
- ...(pendingRunContext ? { pendingRunContext } : {}),
160
- });
161
- }
162
- return;
183
+ appendWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
184
+ let eventType;
185
+ let dedupeKey;
186
+ if (runType === "queue_repair") {
187
+ eventType = "merge_steward_incident";
188
+ dedupeKey = `${dedupeScope}:queue_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
163
189
  }
164
- if (issue.lastGitHubFailureSource === "branch_ci") {
165
- const pendingRunContext = buildFailureContext(issue);
166
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
167
- this.advanceIdleIssue(issue, "repairing_ci");
168
- }
169
- else {
170
- this.advanceIdleIssue(issue, "repairing_ci", {
171
- pendingRunType: "ci_repair",
172
- ...(pendingRunContext ? { pendingRunContext } : {}),
173
- });
174
- }
175
- return;
190
+ else if (runType === "ci_repair") {
191
+ eventType = "settled_red_ci";
192
+ dedupeKey = `${dedupeScope}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
176
193
  }
177
- if (issue.factoryState === "awaiting_queue") {
178
- const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
179
- const inferProtocol = resolveMergeQueueProtocol(inferProject);
180
- let inferred = "branch_ci";
181
- const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
182
- if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
183
- try {
184
- const { stdout } = await execCommand("gh", [
185
- "api",
186
- `repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
187
- "--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
188
- ], { timeoutMs: 10_000 });
189
- if (stdout.trim().length > 0)
190
- inferred = "queue_eviction";
191
- }
192
- catch { /* best effort */ }
193
- }
194
+ else if (runType === "review_fix") {
195
+ eventType = "review_changes_requested";
196
+ dedupeKey = `${dedupeScope}:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
197
+ }
198
+ else {
199
+ eventType = "delegated";
200
+ dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
201
+ }
202
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
203
+ projectId: issue.projectId,
204
+ linearIssueId: issue.linearIssueId,
205
+ eventType,
206
+ ...(context ? { eventJson: JSON.stringify(context) } : {}),
207
+ dedupeKey,
208
+ });
209
+ }
210
+ async routeFailedIssue(issue) {
211
+ issue = await this.refreshMissingFailureProvenance(issue);
212
+ issue = await this.reclassifyStaleBranchFailure(issue);
213
+ const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
214
+ const ignoreDuplicateAttempt = latestRun?.status === "failed"
215
+ && latestRun.failureReason === "Codex turn was interrupted";
216
+ const reactiveIntent = deriveIssueSessionReactiveIntent({
217
+ prNumber: issue.prNumber,
218
+ prState: issue.prState,
219
+ prReviewState: issue.prReviewState,
220
+ prCheckStatus: issue.prCheckStatus,
221
+ latestFailureSource: issue.lastGitHubFailureSource,
222
+ });
223
+ if (!reactiveIntent && issue.factoryState === "awaiting_queue") {
224
+ const inferred = await this.inferFailureSourceFromGitHub(issue) ?? "branch_ci";
194
225
  const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
195
226
  const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
196
227
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
@@ -201,17 +232,126 @@ export class IdleIssueReconciler {
201
232
  });
202
233
  return;
203
234
  }
235
+ if (!reactiveIntent) {
236
+ return;
237
+ }
204
238
  const pendingRunContext = buildFailureContext(issue);
205
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
206
- this.advanceIdleIssue(issue, "repairing_ci");
239
+ const duplicateRepair = reactiveIntent.runType !== "review_fix"
240
+ && !ignoreDuplicateAttempt
241
+ && isDuplicateRepairAttempt(issue, pendingRunContext);
242
+ if (duplicateRepair) {
243
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState);
207
244
  }
208
245
  else {
209
- this.advanceIdleIssue(issue, "repairing_ci", {
210
- pendingRunType: "ci_repair",
246
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
247
+ pendingRunType: reactiveIntent.runType,
211
248
  ...(pendingRunContext ? { pendingRunContext } : {}),
212
249
  });
213
250
  }
214
251
  }
252
+ async refreshMissingFailureProvenance(issue) {
253
+ if (issue.lastGitHubFailureSource || !issue.prNumber || !isFailingCheckStatus(issue.prCheckStatus)) {
254
+ return issue;
255
+ }
256
+ const inferred = await this.inferFailureSourceFromGitHub(issue);
257
+ if (!inferred)
258
+ return issue;
259
+ const protocol = this.getIssueProtocol(issue);
260
+ const failureHeadSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha ?? issue.prHeadSha ?? null;
261
+ const checkName = inferred === "queue_eviction"
262
+ ? issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName
263
+ : issue.lastGitHubFailureCheckName ?? null;
264
+ const failureSignature = issue.lastGitHubFailureSignature
265
+ ?? (inferred === "queue_eviction" && failureHeadSha && checkName
266
+ ? ["queue_eviction", failureHeadSha, checkName].join("::")
267
+ : null);
268
+ this.db.upsertIssue({
269
+ projectId: issue.projectId,
270
+ linearIssueId: issue.linearIssueId,
271
+ lastGitHubFailureSource: inferred,
272
+ ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
273
+ ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
274
+ ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
275
+ });
276
+ const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
277
+ if (!refreshed)
278
+ return issue;
279
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
280
+ return refreshed;
281
+ }
282
+ async reclassifyStaleBranchFailure(issue) {
283
+ const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved";
284
+ if (issue.lastGitHubFailureSource !== "branch_ci" || !downstreamOwned) {
285
+ return issue;
286
+ }
287
+ const inferred = await this.inferFailureSourceFromGitHub(issue);
288
+ if (inferred !== "queue_eviction") {
289
+ return issue;
290
+ }
291
+ const protocol = this.getIssueProtocol(issue);
292
+ const failureHeadSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha ?? issue.prHeadSha ?? null;
293
+ const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
294
+ const failureSignature = issue.lastGitHubFailureSignature
295
+ ?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
296
+ this.db.upsertIssue({
297
+ projectId: issue.projectId,
298
+ linearIssueId: issue.linearIssueId,
299
+ lastGitHubFailureSource: "queue_eviction",
300
+ ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
301
+ ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
302
+ ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
303
+ });
304
+ const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
305
+ if (!refreshed)
306
+ return issue;
307
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
308
+ return refreshed;
309
+ }
310
+ async inferFailureSourceFromGitHub(issue) {
311
+ const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
312
+ const repoFullName = project?.github?.repoFullName;
313
+ const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha ?? issue.prHeadSha;
314
+ if (!repoFullName || !issue.prNumber || !probeSha)
315
+ return undefined;
316
+ const protocol = this.getIssueProtocol(issue);
317
+ try {
318
+ const { stdout } = await execCommand("gh", [
319
+ "api",
320
+ `repos/${repoFullName}/commits/${probeSha}/check-runs`,
321
+ "--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
322
+ ], { timeoutMs: 10_000 });
323
+ if (stdout.trim().length > 0)
324
+ return "queue_eviction";
325
+ }
326
+ catch {
327
+ // Fall through to a PR-level probe. Preemptive conflicts can require
328
+ // queue repair even when no merge-steward eviction check-run exists yet.
329
+ }
330
+ try {
331
+ const { stdout } = await execCommand("gh", [
332
+ "pr", "view", String(issue.prNumber),
333
+ "--repo", repoFullName,
334
+ "--json", "mergeable,mergeStateStatus,labels",
335
+ ], { timeoutMs: 10_000 });
336
+ const pr = JSON.parse(stdout);
337
+ const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved";
338
+ if ((pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY")
339
+ && downstreamOwned) {
340
+ return "queue_eviction";
341
+ }
342
+ if (pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") {
343
+ return undefined;
344
+ }
345
+ }
346
+ catch {
347
+ return issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" ? "branch_ci" : undefined;
348
+ }
349
+ return "branch_ci";
350
+ }
351
+ getIssueProtocol(issue) {
352
+ const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
353
+ return resolveMergeQueueProtocol(project);
354
+ }
215
355
  async reconcileFromGitHub(issue) {
216
356
  const project = this.config.projects.find((p) => p.id === issue.projectId);
217
357
  if (!project?.github?.repoFullName || !issue.prNumber)
@@ -220,9 +360,31 @@ export class IdleIssueReconciler {
220
360
  const { stdout } = await execCommand("gh", [
221
361
  "pr", "view", String(issue.prNumber),
222
362
  "--repo", project.github.repoFullName,
223
- "--json", "state,reviewDecision,mergeable,mergeStateStatus",
363
+ "--json", "headRefOid,state,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup",
224
364
  ], { timeoutMs: 10_000 });
225
365
  const pr = JSON.parse(stdout);
366
+ const gateCheckNames = getGateCheckNames(project);
367
+ const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
368
+ this.db.upsertIssue({
369
+ projectId: issue.projectId,
370
+ linearIssueId: issue.linearIssueId,
371
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
372
+ ...(pr.state === "OPEN" ? { prState: "open" } : {}),
373
+ ...(pr.reviewDecision === "APPROVED"
374
+ ? { prReviewState: "approved" }
375
+ : pr.reviewDecision === "CHANGES_REQUESTED"
376
+ ? { prReviewState: "changes_requested" }
377
+ : {}),
378
+ ...(gateCheckStatus ? { prCheckStatus: gateCheckStatus } : {}),
379
+ ...(pr.headRefOid && gateCheckStatus
380
+ ? {
381
+ lastGitHubCiSnapshotHeadSha: pr.headRefOid,
382
+ lastGitHubCiSnapshotGateCheckName: gateCheckNames[0] ?? "verify",
383
+ lastGitHubCiSnapshotGateCheckStatus: gateCheckStatus,
384
+ lastGitHubCiSnapshotSettledAt: gateCheckStatus === "pending" ? null : new Date().toISOString(),
385
+ }
386
+ : {}),
387
+ });
226
388
  if (pr.state === "MERGED") {
227
389
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
228
390
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
@@ -237,16 +399,22 @@ export class IdleIssueReconciler {
237
399
  });
238
400
  return;
239
401
  }
240
- if (pr.reviewDecision === "APPROVED") {
241
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
242
- this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
243
- return;
244
- }
245
- // Merge conflict detected — dispatch a repair run to rebase the branch.
246
- if (pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") {
247
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR has merge conflicts, dispatching rebase");
248
- this.advanceIdleIssue(issue, "repairing_queue", {
249
- pendingRunType: "queue_repair",
402
+ const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" || pr.reviewDecision === "APPROVED";
403
+ const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
404
+ const refreshedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
405
+ const reactiveIntent = deriveIssueSessionReactiveIntent({
406
+ prNumber: refreshedIssue.prNumber,
407
+ prState: refreshedIssue.prState,
408
+ prReviewState: refreshedIssue.prReviewState,
409
+ prCheckStatus: refreshedIssue.prCheckStatus,
410
+ latestFailureSource: refreshedIssue.lastGitHubFailureSource,
411
+ mergeConflictDetected,
412
+ downstreamOwned,
413
+ });
414
+ if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
415
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
416
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
417
+ pendingRunType: reactiveIntent.runType,
250
418
  pendingRunContext: {
251
419
  source: "idle_reconciliation",
252
420
  failureReason: "merge_conflict_detected",
@@ -258,14 +426,38 @@ export class IdleIssueReconciler {
258
426
  kind: "github",
259
427
  issueKey: issue.issueKey,
260
428
  projectId: issue.projectId,
261
- stage: "repairing_queue",
429
+ stage: reactiveIntent.compatibilityFactoryState,
262
430
  status: "conflict_detected",
263
431
  summary: `PR #${issue.prNumber} has merge conflicts with main, dispatching rebase`,
264
432
  });
433
+ return;
434
+ }
435
+ if (pr.reviewDecision === "APPROVED") {
436
+ this.db.upsertIssue({
437
+ projectId: issue.projectId,
438
+ linearIssueId: issue.linearIssueId,
439
+ prReviewState: "approved",
440
+ });
441
+ if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
442
+ this.advanceIdleIssue(issue, "awaiting_queue", {
443
+ ...(hasFailureProvenance(issue) ? { clearFailureProvenance: true } : {}),
444
+ });
445
+ }
446
+ return;
447
+ }
448
+ if (mergeConflictDetected) {
449
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR is dirty but not yet queue-admitted; leaving PatchRelay in review state");
265
450
  }
266
451
  }
267
452
  catch (error) {
268
453
  this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
454
+ if (issue.prReviewState === "approved") {
455
+ if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
456
+ this.advanceIdleIssue(issue, "awaiting_queue", {
457
+ ...(hasFailureProvenance(issue) ? { clearFailureProvenance: true } : {}),
458
+ });
459
+ }
460
+ }
269
461
  }
270
462
  }
271
463
  }