patchrelay 0.31.0 → 0.32.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.
@@ -75,6 +75,21 @@ export function createGitHubFailureContextResolver() {
75
75
  },
76
76
  };
77
77
  }
78
+ export function createGitHubCiSnapshotResolver() {
79
+ return {
80
+ resolve: async ({ repoFullName, event, gateCheckNames }) => {
81
+ if (!repoFullName || !event.headSha)
82
+ return undefined;
83
+ try {
84
+ const checks = await resolveCheckSnapshotChecks(repoFullName, event.headSha);
85
+ return buildCiSnapshotFromChecks(checks, event, gateCheckNames);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ },
91
+ };
92
+ }
78
93
  export function parseGitHubFailureContext(value) {
79
94
  if (!value)
80
95
  return undefined;
@@ -123,6 +138,18 @@ async function resolveFailedCheckRun(repoFullName, event) {
123
138
  ?? checks.find((entry) => entry.name && event.checkName && entry.name.includes(event.checkName))
124
139
  ?? checks[0];
125
140
  }
141
+ async function resolveCheckSnapshotChecks(repoFullName, headSha) {
142
+ const response = await execCommand("gh", [
143
+ "api",
144
+ `repos/${repoFullName}/commits/${headSha}/check-runs`,
145
+ "--method", "GET",
146
+ ], { timeoutMs: 15_000 });
147
+ if (response.exitCode !== 0) {
148
+ throw new Error(response.stderr || "gh api check-runs failed");
149
+ }
150
+ const payload = safeJsonParse(response.stdout);
151
+ return (payload?.check_runs ?? []).map(mapCiSnapshotCheck).filter((entry) => Boolean(entry));
152
+ }
126
153
  async function resolveWorkflowJob(repoFullName, workflowRunId, preferredName) {
127
154
  const response = await execCommand("gh", [
128
155
  "api",
@@ -173,6 +200,22 @@ function mapCheckRunSummary(row) {
173
200
  ...(typeof output?.text === "string" ? { outputText: sanitizeDiagnosticText(output.text, 240) } : {}),
174
201
  };
175
202
  }
203
+ function mapCiSnapshotCheck(row) {
204
+ if (typeof row.name !== "string" || !row.name.trim())
205
+ return undefined;
206
+ const output = row.output && typeof row.output === "object" ? row.output : undefined;
207
+ const status = deriveCheckStatus({
208
+ apiStatus: typeof row.status === "string" ? row.status : undefined,
209
+ apiConclusion: typeof row.conclusion === "string" ? row.conclusion : undefined,
210
+ });
211
+ return {
212
+ name: row.name.trim(),
213
+ status,
214
+ ...(typeof row.conclusion === "string" && row.conclusion.trim() ? { conclusion: row.conclusion.trim().toLowerCase() } : {}),
215
+ ...(typeof row.details_url === "string" && row.details_url.trim() ? { detailsUrl: row.details_url.trim() } : {}),
216
+ ...(firstNonEmpty(typeof output?.title === "string" ? output.title : undefined, typeof output?.summary === "string" ? sanitizeDiagnosticText(output.summary, 240) : undefined) ? { summary: firstNonEmpty(typeof output?.title === "string" ? output.title : undefined, typeof output?.summary === "string" ? sanitizeDiagnosticText(output.summary, 240) : undefined) } : {}),
217
+ };
218
+ }
176
219
  function mapWorkflowJobSummary(row) {
177
220
  const steps = Array.isArray(row.steps) ? row.steps.filter((entry) => Boolean(entry) && typeof entry === "object") : [];
178
221
  const failedStep = steps.find((entry) => {
@@ -192,6 +235,56 @@ function parseWorkflowRunId(url) {
192
235
  const match = url.match(/\/actions\/runs\/(\d+)/);
193
236
  return match ? Number(match[1]) : undefined;
194
237
  }
238
+ function buildCiSnapshotFromChecks(checks, event, gateCheckNames) {
239
+ const gateCheck = findGateCheck(checks, gateCheckNames, event.checkName);
240
+ const gateCheckName = gateCheck?.name ?? pickGateCheckName(gateCheckNames, event.checkName) ?? event.checkName;
241
+ const gateCheckStatus = gateCheck?.status ?? deriveCheckStatus({
242
+ eventStatus: event.checkStatus,
243
+ eventConclusion: event.triggerEvent === "check_passed" ? "success" : "failure",
244
+ });
245
+ const failedChecks = checks.filter((entry) => entry.status === "failure");
246
+ return {
247
+ headSha: event.headSha,
248
+ ...(gateCheckName ? { gateCheckName } : {}),
249
+ gateCheckStatus,
250
+ failedChecks,
251
+ checks,
252
+ ...(gateCheckStatus !== "pending" ? { settledAt: new Date().toISOString() } : {}),
253
+ capturedAt: new Date().toISOString(),
254
+ };
255
+ }
256
+ function findGateCheck(checks, gateCheckNames, fallbackCheckName) {
257
+ const exactNames = gateCheckNames.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
258
+ if (exactNames.length > 0) {
259
+ const exact = checks.find((entry) => exactNames.includes(entry.name.trim().toLowerCase()));
260
+ if (exact)
261
+ return exact;
262
+ }
263
+ if (!fallbackCheckName)
264
+ return undefined;
265
+ const fallback = fallbackCheckName.trim().toLowerCase();
266
+ return checks.find((entry) => entry.name.trim().toLowerCase() === fallback);
267
+ }
268
+ function pickGateCheckName(gateCheckNames, fallbackCheckName) {
269
+ return gateCheckNames.find((entry) => entry.trim().length > 0)?.trim()
270
+ ?? fallbackCheckName?.trim();
271
+ }
272
+ function deriveCheckStatus(params) {
273
+ const status = params.apiStatus?.trim().toLowerCase();
274
+ if (status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending") {
275
+ return "pending";
276
+ }
277
+ const conclusion = params.apiConclusion?.trim().toLowerCase()
278
+ ?? params.eventConclusion?.trim().toLowerCase()
279
+ ?? params.eventStatus?.trim().toLowerCase();
280
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
281
+ return "success";
282
+ }
283
+ if (conclusion && FAILED_CONCLUSIONS.has(conclusion)) {
284
+ return "failure";
285
+ }
286
+ return status === "completed" ? "failure" : "pending";
287
+ }
195
288
  function buildFailureSignature(parts) {
196
289
  return [
197
290
  parts.source,
@@ -1,5 +1,5 @@
1
1
  import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
2
- import { createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
2
+ import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
3
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
5
5
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
@@ -10,12 +10,11 @@ import { resolveSecret } from "./resolve-secret.js";
10
10
  import { safeJsonParse } from "./utils.js";
11
11
  /**
12
12
  * GitHub sends both check_run and check_suite completion events.
13
- * A single CI run generates 10+ individual check_run events as each job finishes,
14
- * but only 1 check_suite event when the entire suite completes. Reacting to
15
- * individual check_run events causes the factory state to flicker rapidly
16
- * between pr_open and repairing_ci. We only drive state transitions and reactive
17
- * runs from check_suite events. Individual check_run events still update PR
18
- * metadata (prCheckStatus) for observability.
13
+ * A single CI run generates many individual check_run events as each job finishes,
14
+ * but PatchRelay should only start ci_repair once the configured gate check
15
+ * (for example `Tests`) has gone terminal for the current PR head SHA. We still
16
+ * treat most check_run events as metadata-only and only react to queue eviction
17
+ * checks or the settled gate check.
19
18
  */
20
19
  function isMetadataOnlyCheckEvent(event) {
21
20
  return event.eventSource === "check_run"
@@ -30,7 +29,8 @@ export class GitHubWebhookHandler {
30
29
  codex;
31
30
  feed;
32
31
  failureContextResolver;
33
- constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver()) {
32
+ ciSnapshotResolver;
33
+ constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver()) {
34
34
  this.config = config;
35
35
  this.db = db;
36
36
  this.linearProvider = linearProvider;
@@ -39,6 +39,7 @@ export class GitHubWebhookHandler {
39
39
  this.codex = codex;
40
40
  this.feed = feed;
41
41
  this.failureContextResolver = failureContextResolver;
42
+ this.ciSnapshotResolver = ciSnapshotResolver;
42
43
  }
43
44
  async acceptGitHubWebhook(params) {
44
45
  // Deduplicate
@@ -128,6 +129,7 @@ export class GitHubWebhookHandler {
128
129
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
129
130
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
130
131
  });
132
+ await this.updateCiSnapshot(issue, event, project);
131
133
  await this.updateFailureProvenance(issue, event, project);
132
134
  if (!isMetadataOnlyCheckEvent(event)) {
133
135
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
@@ -144,6 +146,9 @@ export class GitHubWebhookHandler {
144
146
  linearIssueId: issue.linearIssueId,
145
147
  factoryState: newState,
146
148
  });
149
+ if (newState === "awaiting_queue") {
150
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
151
+ }
147
152
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
148
153
  const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
149
154
  void this.emitLinearActivity(transitionedIssue, newState, event);
@@ -178,6 +183,11 @@ export class GitHubWebhookHandler {
178
183
  lastGitHubFailureCheckUrl: null,
179
184
  lastGitHubFailureContextJson: null,
180
185
  lastGitHubFailureAt: null,
186
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
187
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
188
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
189
+ lastGitHubCiSnapshotJson: null,
190
+ lastGitHubCiSnapshotSettledAt: null,
181
191
  lastQueueIncidentJson: null,
182
192
  lastAttemptedFailureHeadSha: null,
183
193
  lastAttemptedFailureSignature: null,
@@ -197,14 +207,85 @@ export class GitHubWebhookHandler {
197
207
  // Queue eviction check runs bypass the metadata-only filter because
198
208
  // they're individual check_run events (not check_suite), but they
199
209
  // must drive state transitions.
200
- const protocol = resolveMergeQueueProtocol(project);
201
- if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
210
+ if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
202
211
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
203
212
  }
204
213
  else if (!isMetadataOnlyCheckEvent(event)) {
205
214
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
206
215
  }
207
216
  }
217
+ async updateCiSnapshot(issue, event, project) {
218
+ if (event.triggerEvent === "pr_merged") {
219
+ this.db.upsertIssue({
220
+ projectId: issue.projectId,
221
+ linearIssueId: issue.linearIssueId,
222
+ lastGitHubCiSnapshotHeadSha: null,
223
+ lastGitHubCiSnapshotGateCheckName: null,
224
+ lastGitHubCiSnapshotGateCheckStatus: null,
225
+ lastGitHubCiSnapshotJson: null,
226
+ lastGitHubCiSnapshotSettledAt: null,
227
+ });
228
+ return;
229
+ }
230
+ if (event.triggerEvent === "pr_synchronize") {
231
+ this.db.upsertIssue({
232
+ projectId: issue.projectId,
233
+ linearIssueId: issue.linearIssueId,
234
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
235
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
236
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
237
+ lastGitHubCiSnapshotJson: null,
238
+ lastGitHubCiSnapshotSettledAt: null,
239
+ });
240
+ return;
241
+ }
242
+ if (issue.prState !== "open")
243
+ return;
244
+ if (event.eventSource !== "check_run")
245
+ return;
246
+ if (this.isQueueEvictionFailure(issue, event, project))
247
+ return;
248
+ if (!this.isGateCheckEvent(event, project))
249
+ return;
250
+ if (this.isStaleGateEvent(issue, event))
251
+ return;
252
+ const snapshot = await this.ciSnapshotResolver.resolve({
253
+ repoFullName: project?.github?.repoFullName ?? event.repoFullName,
254
+ event,
255
+ gateCheckNames: this.getGateCheckNames(project),
256
+ });
257
+ if (!snapshot) {
258
+ this.db.upsertIssue({
259
+ projectId: issue.projectId,
260
+ linearIssueId: issue.linearIssueId,
261
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
262
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
263
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
264
+ lastGitHubCiSnapshotJson: null,
265
+ lastGitHubCiSnapshotSettledAt: null,
266
+ });
267
+ this.logger.warn({ issueKey: issue.issueKey, repoFullName: project?.github?.repoFullName ?? event.repoFullName, headSha: event.headSha }, "Could not resolve settled CI snapshot; waiting before CI repair");
268
+ this.feed?.publish({
269
+ level: "warn",
270
+ kind: "github",
271
+ issueKey: issue.issueKey,
272
+ projectId: issue.projectId,
273
+ stage: issue.factoryState,
274
+ status: "ci_snapshot_unavailable",
275
+ summary: `Could not resolve settled ${this.getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
276
+ });
277
+ return;
278
+ }
279
+ this.db.upsertIssue({
280
+ projectId: issue.projectId,
281
+ linearIssueId: issue.linearIssueId,
282
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha,
283
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
284
+ lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
285
+ lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
286
+ lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
287
+ });
288
+ }
208
289
  async maybeEnqueueReactiveRun(issue, event, project) {
209
290
  // Don't trigger if there's already an active run
210
291
  if (issue.activeRunId !== undefined)
@@ -216,10 +297,7 @@ export class GitHubWebhookHandler {
216
297
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
217
298
  // External merge queue eviction: react only to the configured check
218
299
  // name, not to any CI failure. Regular CI failures still get ci_repair.
219
- const protocol = resolveMergeQueueProtocol(project);
220
- const queueCheckName = protocol.evictionCheckName;
221
- if (issue.factoryState === "awaiting_queue"
222
- && event.checkName === queueCheckName) {
300
+ if (this.isQueueEvictionFailure(issue, event, project)) {
223
301
  const queueRepairContext = buildQueueRepairContextFromEvent(event);
224
302
  const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
225
303
  if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
@@ -243,6 +321,7 @@ export class GitHubWebhookHandler {
243
321
  lastQueueSignalAt: new Date().toISOString(),
244
322
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
245
323
  });
324
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
246
325
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
247
326
  this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
248
327
  this.feed?.publish({
@@ -257,10 +336,23 @@ export class GitHubWebhookHandler {
257
336
  });
258
337
  }
259
338
  else {
339
+ if (!this.isSettledBranchFailure(issue, event, project)) {
340
+ this.feed?.publish({
341
+ level: "info",
342
+ kind: "github",
343
+ issueKey: issue.issueKey,
344
+ projectId: issue.projectId,
345
+ stage: issue.factoryState,
346
+ status: "ci_waiting_for_settlement",
347
+ summary: `Waiting for settled ${this.getPrimaryGateCheckName(project)} result before starting CI repair`,
348
+ });
349
+ return;
350
+ }
260
351
  const failureContext = await this.resolveBranchFailureContext(issue, event, project);
261
352
  if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
262
353
  return;
263
354
  }
355
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
264
356
  this.db.upsertIssue({
265
357
  projectId: issue.projectId,
266
358
  linearIssueId: issue.linearIssueId,
@@ -268,6 +360,7 @@ export class GitHubWebhookHandler {
268
360
  pendingRunContextJson: JSON.stringify({
269
361
  ...failureContext,
270
362
  checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
363
+ ...(snapshot ? { ciSnapshot: snapshot } : {}),
271
364
  }),
272
365
  lastGitHubFailureSource: "branch_ci",
273
366
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
@@ -278,6 +371,7 @@ export class GitHubWebhookHandler {
278
371
  lastGitHubFailureAt: new Date().toISOString(),
279
372
  lastQueueIncidentJson: null,
280
373
  });
374
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
281
375
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
282
376
  this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
283
377
  this.feed?.publish({
@@ -302,17 +396,20 @@ export class GitHubWebhookHandler {
302
396
  reviewerName: event.reviewerName,
303
397
  }),
304
398
  });
399
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
305
400
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
306
401
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
307
402
  }
308
403
  }
309
404
  async updateFailureProvenance(issue, event, project) {
310
- const protocol = resolveMergeQueueProtocol(project);
311
- const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
405
+ const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
312
406
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
313
- const source = issue.factoryState === "awaiting_queue" && isQueueEvictionCheck
407
+ const source = isQueueEvictionCheck
314
408
  ? "queue_eviction"
315
409
  : "branch_ci";
410
+ if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
411
+ return;
412
+ }
316
413
  const failureContext = source === "queue_eviction"
317
414
  ? this.buildQueueFailureContext(issue, event)
318
415
  : await this.resolveBranchFailureContext(issue, event, project);
@@ -337,9 +434,12 @@ export class GitHubWebhookHandler {
337
434
  });
338
435
  return;
339
436
  }
340
- if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
437
+ if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
341
438
  || event.triggerEvent === "pr_synchronize"
342
439
  || event.triggerEvent === "pr_merged") {
440
+ if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
441
+ return;
442
+ }
343
443
  this.db.upsertIssue({
344
444
  projectId: issue.projectId,
345
445
  linearIssueId: issue.linearIssueId,
@@ -358,10 +458,19 @@ export class GitHubWebhookHandler {
358
458
  }
359
459
  async resolveBranchFailureContext(issue, event, project) {
360
460
  const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
461
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
462
+ const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
361
463
  const context = await this.failureContextResolver.resolve({
362
464
  source: "branch_ci",
363
465
  repoFullName,
364
- event,
466
+ event: primaryFailedCheck
467
+ ? {
468
+ ...event,
469
+ checkName: primaryFailedCheck.name,
470
+ checkUrl: primaryFailedCheck.detailsUrl ?? event.checkUrl,
471
+ checkDetailsUrl: primaryFailedCheck.detailsUrl ?? event.checkDetailsUrl,
472
+ }
473
+ : event,
365
474
  });
366
475
  return {
367
476
  ...(context ? context : {}),
@@ -433,6 +542,65 @@ export class GitHubWebhookHandler {
433
542
  }
434
543
  return false;
435
544
  }
545
+ getGateCheckNames(project) {
546
+ const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
547
+ return configured.length > 0 ? configured : ["Tests"];
548
+ }
549
+ getPrimaryGateCheckName(project) {
550
+ return this.getGateCheckNames(project)[0] ?? "Tests";
551
+ }
552
+ isGateCheckEvent(event, project) {
553
+ if (event.eventSource !== "check_run" || !event.checkName)
554
+ return false;
555
+ const normalized = event.checkName.trim().toLowerCase();
556
+ return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
557
+ }
558
+ isStaleGateEvent(issue, event) {
559
+ return Boolean(issue.lastGitHubCiSnapshotHeadSha
560
+ && event.headSha
561
+ && issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
562
+ }
563
+ isQueueEvictionFailure(issue, event, project) {
564
+ const protocol = resolveMergeQueueProtocol(project);
565
+ return issue.factoryState === "awaiting_queue"
566
+ && event.eventSource === "check_run"
567
+ && event.checkName === protocol.evictionCheckName;
568
+ }
569
+ isSettledBranchFailure(issue, event, project) {
570
+ if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
571
+ return false;
572
+ if (!this.isGateCheckEvent(event, project))
573
+ return false;
574
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
575
+ return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
576
+ }
577
+ canClearFailureProvenance(issue, event, project) {
578
+ if (event.triggerEvent !== "check_passed")
579
+ return true;
580
+ if (this.isQueueEvictionFailure(issue, event, project)) {
581
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
582
+ }
583
+ if (!this.isGateCheckEvent(event, project)) {
584
+ return true;
585
+ }
586
+ if (this.isStaleGateEvent(issue, event)) {
587
+ return false;
588
+ }
589
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
590
+ }
591
+ getRelevantCiSnapshot(issue, event) {
592
+ const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
593
+ if (!snapshot)
594
+ return undefined;
595
+ if (snapshot.headSha !== event.headSha)
596
+ return undefined;
597
+ return snapshot;
598
+ }
599
+ pickPrimaryFailedCheck(snapshot) {
600
+ const gateName = snapshot.gateCheckName?.trim().toLowerCase();
601
+ return snapshot.failedChecks.find((entry) => entry.name.trim().toLowerCase() !== gateName)
602
+ ?? snapshot.failedChecks[0];
603
+ }
436
604
  async emitLinearActivity(issue, newState, event) {
437
605
  if (!issue.agentSessionId)
438
606
  return;
@@ -370,9 +370,9 @@ export class DatabaseBackedLinearClientProvider {
370
370
  this.logger = logger;
371
371
  }
372
372
  async forProject(projectId) {
373
- const link = this.db.linearInstallations.getProjectInstallation(projectId);
374
- if (link) {
375
- return await this.forInstallationId(link.installationId);
373
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
374
+ if (installation) {
375
+ return await this.forInstallationId(installation.id);
376
376
  }
377
377
  return undefined;
378
378
  }
@@ -57,11 +57,17 @@ function buildRunPrompt(issue, runType, repoPath, context) {
57
57
  }
58
58
  // Add run-type-specific context for reactive runs
59
59
  switch (runType) {
60
- case "ci_repair":
61
- lines.push("## CI Repair", "", "A CI check has failed on your PR. Fix the failure and push.", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
60
+ case "ci_repair": {
61
+ const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
62
+ ? context.ciSnapshot
63
+ : undefined;
64
+ lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, fix that concrete failure first, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
65
+ ? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
66
+ : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
62
67
  ? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
63
- : "", "", "Read the CI failure logs, fix the code issue, run verification, commit and push.", "Do not change test expectations unless the test is genuinely wrong.", "");
68
+ : "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "Read the latest logs for the named failing check, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
64
69
  break;
70
+ }
65
71
  case "review_fix":
66
72
  lines.push("## Review Changes Requested", "", "A reviewer has requested changes on your PR. Address the feedback and push.", context?.reviewerName ? `Reviewer: ${String(context.reviewerName)}` : "", context?.reviewBody ? `\n## Review comment\n\n${String(context.reviewBody)}` : "", "", "Steps:", "1. Read the review feedback and PR comments (`gh pr view --comments`).", "2. Check the current diff (`git diff origin/main`) — a prior rebase may have already resolved some concerns (e.g., scope-bundling from stale commits).", "3. For each review point: if already resolved, note why. If not, fix it.", "4. Run verification, commit and push.", "5. If you believe all concerns are resolved, request a re-review: `gh pr edit <PR#> --add-reviewer <reviewer>`.", " Do NOT just post a comment saying \"resolved\" — the reviewer must re-review to dismiss the CHANGES_REQUESTED state.", "");
67
73
  break;
@@ -182,6 +188,7 @@ export class RunOrchestrator {
182
188
  }
183
189
  : {}),
184
190
  });
191
+ this.db.setBranchOwner(item.projectId, item.issueId, "patchrelay");
185
192
  return created;
186
193
  });
187
194
  if (!run)
@@ -287,8 +294,9 @@ export class RunOrchestrator {
287
294
  * Risks mitigated:
288
295
  * - Dirty worktree from interrupted run → stash before, pop after
289
296
  * - Conflicts → abort rebase, throw so the run fails with a clear reason
290
- * - Already up-to-date → no-op, no force-push needed
291
- * - Force-push invalidates reviews only push if rebase actually moved commits
297
+ * - Already up-to-date → no-op
298
+ * - Keep publishing explicit: the orchestrator updates the local worktree
299
+ * only; the agent/run owns any later branch push.
292
300
  */
293
301
  async freshenWorktree(worktreePath, project, issue) {
294
302
  const gitBin = this.config.runner.gitBin;
@@ -325,14 +333,7 @@ export class RunOrchestrator {
325
333
  this.logger.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
326
334
  return;
327
335
  }
328
- // Push the rebased branch (force-with-lease to protect against concurrent pushes)
329
- const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
330
- if (pushResult.exitCode !== 0) {
331
- this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Pre-run rebase push failed, proceeding anyway");
332
- }
333
- else {
334
- this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased and pushed onto latest base");
335
- }
336
+ this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased locally onto latest base");
336
337
  // Restore stashed changes
337
338
  if (didStash)
338
339
  await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
@@ -464,6 +465,9 @@ export class RunOrchestrator {
464
465
  }
465
466
  : {}),
466
467
  });
468
+ if (postRunState === "awaiting_queue") {
469
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
470
+ }
467
471
  });
468
472
  // If we advanced to awaiting_queue, enqueue for merge prep
469
473
  if (postRunState === "awaiting_queue") {
@@ -564,7 +568,7 @@ export class RunOrchestrator {
564
568
  }
565
569
  // Review approved + checks not failed — advance to awaiting_queue
566
570
  if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
567
- if (issue.factoryState !== "awaiting_queue") {
571
+ if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
568
572
  this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
569
573
  }
570
574
  continue;
@@ -682,6 +686,10 @@ export class RunOrchestrator {
682
686
  }
683
687
  : {}),
684
688
  });
689
+ const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
690
+ if (branchOwner) {
691
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
692
+ }
685
693
  this.feed?.publish({
686
694
  level: "info",
687
695
  kind: "stage",
@@ -691,7 +699,7 @@ export class RunOrchestrator {
691
699
  status: "reconciled",
692
700
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
693
701
  });
694
- if (newState === "awaiting_queue") {
702
+ if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
695
703
  this.requestMergeQueueAdmission(issue, issue.projectId);
696
704
  }
697
705
  if (options?.pendingRunType) {
@@ -914,6 +922,9 @@ export class RunOrchestrator {
914
922
  }
915
923
  : {}),
916
924
  });
925
+ if (postRunState === "awaiting_queue") {
926
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
927
+ }
917
928
  });
918
929
  if (postRunState) {
919
930
  this.feed?.publish({
@@ -981,8 +992,21 @@ export class RunOrchestrator {
981
992
  activeRunId: null,
982
993
  factoryState: nextState,
983
994
  });
995
+ const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
996
+ if (branchOwner) {
997
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, branchOwner);
998
+ }
984
999
  });
985
1000
  }
1001
+ resolveBranchOwnerForStateTransition(newState, pendingRunType) {
1002
+ if (pendingRunType)
1003
+ return "patchrelay";
1004
+ if (newState === "awaiting_queue")
1005
+ return "merge_steward";
1006
+ if (newState === "repairing_ci" || newState === "repairing_queue")
1007
+ return "patchrelay";
1008
+ return undefined;
1009
+ }
986
1010
  async verifyReactiveRunAdvancedBranch(run, issue) {
987
1011
  if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
988
1012
  return undefined;
package/dist/service.js CHANGED
@@ -85,6 +85,10 @@ export class PatchRelayService {
85
85
  });
86
86
  }
87
87
  async start() {
88
+ const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
89
+ for (const repair of repairedInstallations) {
90
+ this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
91
+ }
88
92
  // Verify Linear connectivity for all configured projects before starting.
89
93
  // Auth errors do not prevent startup (the OAuth callback must be reachable
90
94
  // for `patchrelay linear connect`), but the service reports NOT READY until at
@@ -436,7 +440,7 @@ export class PatchRelayService {
436
440
  // Infer run type from current state instead of always resetting to implementation
437
441
  let runType = "implementation";
438
442
  let factoryState = "delegated";
439
- if (issue.prNumber && issue.prCheckStatus === "failed") {
443
+ if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
440
444
  runType = "ci_repair";
441
445
  factoryState = "repairing_ci";
442
446
  }