patchrelay 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.31.0",
4
- "commit": "5de73a74995a",
5
- "builtAt": "2026-04-01T09:38:32.968Z"
3
+ "version": "0.32.0",
4
+ "commit": "21ba6968b0ff",
5
+ "builtAt": "2026-04-01T11:53:53.815Z"
6
6
  }
@@ -204,6 +204,11 @@ export function runPatchRelayMigrations(connection) {
204
204
  addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
205
205
  addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
206
206
  addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
207
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_head_sha", "TEXT");
208
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_name", "TEXT");
209
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_gate_check_status", "TEXT");
210
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_json", "TEXT");
211
+ addColumnIfMissing(connection, "issues", "last_github_ci_snapshot_settled_at", "TEXT");
207
212
  addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
208
213
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
209
214
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
package/dist/db.js CHANGED
@@ -189,6 +189,26 @@ export class PatchRelayDatabase {
189
189
  sets.push("last_github_failure_at = @lastGitHubFailureAt");
190
190
  values.lastGitHubFailureAt = params.lastGitHubFailureAt;
191
191
  }
192
+ if (params.lastGitHubCiSnapshotHeadSha !== undefined) {
193
+ sets.push("last_github_ci_snapshot_head_sha = @lastGitHubCiSnapshotHeadSha");
194
+ values.lastGitHubCiSnapshotHeadSha = params.lastGitHubCiSnapshotHeadSha;
195
+ }
196
+ if (params.lastGitHubCiSnapshotGateCheckName !== undefined) {
197
+ sets.push("last_github_ci_snapshot_gate_check_name = @lastGitHubCiSnapshotGateCheckName");
198
+ values.lastGitHubCiSnapshotGateCheckName = params.lastGitHubCiSnapshotGateCheckName;
199
+ }
200
+ if (params.lastGitHubCiSnapshotGateCheckStatus !== undefined) {
201
+ sets.push("last_github_ci_snapshot_gate_check_status = @lastGitHubCiSnapshotGateCheckStatus");
202
+ values.lastGitHubCiSnapshotGateCheckStatus = params.lastGitHubCiSnapshotGateCheckStatus;
203
+ }
204
+ if (params.lastGitHubCiSnapshotJson !== undefined) {
205
+ sets.push("last_github_ci_snapshot_json = @lastGitHubCiSnapshotJson");
206
+ values.lastGitHubCiSnapshotJson = params.lastGitHubCiSnapshotJson;
207
+ }
208
+ if (params.lastGitHubCiSnapshotSettledAt !== undefined) {
209
+ sets.push("last_github_ci_snapshot_settled_at = @lastGitHubCiSnapshotSettledAt");
210
+ values.lastGitHubCiSnapshotSettledAt = params.lastGitHubCiSnapshotSettledAt;
211
+ }
192
212
  if (params.lastQueueSignalAt !== undefined) {
193
213
  sets.push("last_queue_signal_at = @lastQueueSignalAt");
194
214
  values.lastQueueSignalAt = params.lastQueueSignalAt;
@@ -236,7 +256,9 @@ export class PatchRelayDatabase {
236
256
  branch_name, worktree_path, thread_id, active_run_id,
237
257
  agent_session_id,
238
258
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
239
- last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at, last_queue_signal_at, last_queue_incident_json,
259
+ last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
260
+ last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
261
+ last_queue_signal_at, last_queue_incident_json,
240
262
  last_attempted_failure_head_sha, last_attempted_failure_signature,
241
263
  updated_at
242
264
  ) VALUES (
@@ -246,7 +268,9 @@ export class PatchRelayDatabase {
246
268
  @branchName, @worktreePath, @threadId, @activeRunId,
247
269
  @agentSessionId,
248
270
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
249
- @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
271
+ @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
272
+ @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
273
+ @lastQueueSignalAt, @lastQueueIncidentJson,
250
274
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
251
275
  @now
252
276
  )
@@ -281,6 +305,11 @@ export class PatchRelayDatabase {
281
305
  lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
282
306
  lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
283
307
  lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
308
+ lastGitHubCiSnapshotHeadSha: params.lastGitHubCiSnapshotHeadSha ?? null,
309
+ lastGitHubCiSnapshotGateCheckName: params.lastGitHubCiSnapshotGateCheckName ?? null,
310
+ lastGitHubCiSnapshotGateCheckStatus: params.lastGitHubCiSnapshotGateCheckStatus ?? null,
311
+ lastGitHubCiSnapshotJson: params.lastGitHubCiSnapshotJson ?? null,
312
+ lastGitHubCiSnapshotSettledAt: params.lastGitHubCiSnapshotSettledAt ?? null,
284
313
  lastQueueSignalAt: params.lastQueueSignalAt ?? null,
285
314
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
286
315
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
@@ -381,6 +410,17 @@ export class PatchRelayDatabase {
381
410
  linearIssueId: String(row.linear_issue_id),
382
411
  }));
383
412
  }
413
+ getLatestGitHubCiSnapshot(projectId, linearIssueId) {
414
+ const issue = this.getIssue(projectId, linearIssueId);
415
+ if (!issue?.lastGitHubCiSnapshotJson)
416
+ return undefined;
417
+ try {
418
+ return JSON.parse(issue.lastGitHubCiSnapshotJson);
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ }
384
424
  countUnresolvedBlockers(projectId, linearIssueId) {
385
425
  const row = this.connection.prepare(`
386
426
  SELECT COUNT(*) AS count
@@ -632,6 +672,21 @@ function mapIssueRow(row) {
632
672
  ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
633
673
  ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
634
674
  : {}),
675
+ ...(row.last_github_ci_snapshot_head_sha !== null && row.last_github_ci_snapshot_head_sha !== undefined
676
+ ? { lastGitHubCiSnapshotHeadSha: String(row.last_github_ci_snapshot_head_sha) }
677
+ : {}),
678
+ ...(row.last_github_ci_snapshot_gate_check_name !== null && row.last_github_ci_snapshot_gate_check_name !== undefined
679
+ ? { lastGitHubCiSnapshotGateCheckName: String(row.last_github_ci_snapshot_gate_check_name) }
680
+ : {}),
681
+ ...(row.last_github_ci_snapshot_gate_check_status !== null && row.last_github_ci_snapshot_gate_check_status !== undefined
682
+ ? { lastGitHubCiSnapshotGateCheckStatus: String(row.last_github_ci_snapshot_gate_check_status) }
683
+ : {}),
684
+ ...(row.last_github_ci_snapshot_json !== null && row.last_github_ci_snapshot_json !== undefined
685
+ ? { lastGitHubCiSnapshotJson: String(row.last_github_ci_snapshot_json) }
686
+ : {}),
687
+ ...(row.last_github_ci_snapshot_settled_at !== null && row.last_github_ci_snapshot_settled_at !== undefined
688
+ ? { lastGitHubCiSnapshotSettledAt: String(row.last_github_ci_snapshot_settled_at) }
689
+ : {}),
635
690
  ...(row.last_queue_signal_at !== null && row.last_queue_signal_at !== undefined
636
691
  ? { lastQueueSignalAt: String(row.last_queue_signal_at) }
637
692
  : {}),
@@ -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
@@ -178,6 +180,11 @@ export class GitHubWebhookHandler {
178
180
  lastGitHubFailureCheckUrl: null,
179
181
  lastGitHubFailureContextJson: null,
180
182
  lastGitHubFailureAt: null,
183
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
184
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
185
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
186
+ lastGitHubCiSnapshotJson: null,
187
+ lastGitHubCiSnapshotSettledAt: null,
181
188
  lastQueueIncidentJson: null,
182
189
  lastAttemptedFailureHeadSha: null,
183
190
  lastAttemptedFailureSignature: null,
@@ -197,14 +204,85 @@ export class GitHubWebhookHandler {
197
204
  // Queue eviction check runs bypass the metadata-only filter because
198
205
  // they're individual check_run events (not check_suite), but they
199
206
  // must drive state transitions.
200
- const protocol = resolveMergeQueueProtocol(project);
201
- if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
207
+ if (this.isQueueEvictionFailure(freshIssue, event, project) || this.isGateCheckEvent(event, project)) {
202
208
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
203
209
  }
204
210
  else if (!isMetadataOnlyCheckEvent(event)) {
205
211
  await this.maybeEnqueueReactiveRun(freshIssue, event, project);
206
212
  }
207
213
  }
214
+ async updateCiSnapshot(issue, event, project) {
215
+ if (event.triggerEvent === "pr_merged") {
216
+ this.db.upsertIssue({
217
+ projectId: issue.projectId,
218
+ linearIssueId: issue.linearIssueId,
219
+ lastGitHubCiSnapshotHeadSha: null,
220
+ lastGitHubCiSnapshotGateCheckName: null,
221
+ lastGitHubCiSnapshotGateCheckStatus: null,
222
+ lastGitHubCiSnapshotJson: null,
223
+ lastGitHubCiSnapshotSettledAt: null,
224
+ });
225
+ return;
226
+ }
227
+ if (event.triggerEvent === "pr_synchronize") {
228
+ this.db.upsertIssue({
229
+ projectId: issue.projectId,
230
+ linearIssueId: issue.linearIssueId,
231
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
232
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
233
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
234
+ lastGitHubCiSnapshotJson: null,
235
+ lastGitHubCiSnapshotSettledAt: null,
236
+ });
237
+ return;
238
+ }
239
+ if (issue.prState !== "open")
240
+ return;
241
+ if (event.eventSource !== "check_run")
242
+ return;
243
+ if (this.isQueueEvictionFailure(issue, event, project))
244
+ return;
245
+ if (!this.isGateCheckEvent(event, project))
246
+ return;
247
+ if (this.isStaleGateEvent(issue, event))
248
+ return;
249
+ const snapshot = await this.ciSnapshotResolver.resolve({
250
+ repoFullName: project?.github?.repoFullName ?? event.repoFullName,
251
+ event,
252
+ gateCheckNames: this.getGateCheckNames(project),
253
+ });
254
+ if (!snapshot) {
255
+ this.db.upsertIssue({
256
+ projectId: issue.projectId,
257
+ linearIssueId: issue.linearIssueId,
258
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
259
+ lastGitHubCiSnapshotGateCheckName: this.getPrimaryGateCheckName(project),
260
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
261
+ lastGitHubCiSnapshotJson: null,
262
+ lastGitHubCiSnapshotSettledAt: null,
263
+ });
264
+ 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");
265
+ this.feed?.publish({
266
+ level: "warn",
267
+ kind: "github",
268
+ issueKey: issue.issueKey,
269
+ projectId: issue.projectId,
270
+ stage: issue.factoryState,
271
+ status: "ci_snapshot_unavailable",
272
+ summary: `Could not resolve settled ${this.getPrimaryGateCheckName(project)} snapshot; waiting before CI repair`,
273
+ });
274
+ return;
275
+ }
276
+ this.db.upsertIssue({
277
+ projectId: issue.projectId,
278
+ linearIssueId: issue.linearIssueId,
279
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha,
280
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? this.getPrimaryGateCheckName(project),
281
+ lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
282
+ lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
283
+ lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
284
+ });
285
+ }
208
286
  async maybeEnqueueReactiveRun(issue, event, project) {
209
287
  // Don't trigger if there's already an active run
210
288
  if (issue.activeRunId !== undefined)
@@ -216,10 +294,7 @@ export class GitHubWebhookHandler {
216
294
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
217
295
  // External merge queue eviction: react only to the configured check
218
296
  // 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) {
297
+ if (this.isQueueEvictionFailure(issue, event, project)) {
223
298
  const queueRepairContext = buildQueueRepairContextFromEvent(event);
224
299
  const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
225
300
  if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
@@ -257,10 +332,23 @@ export class GitHubWebhookHandler {
257
332
  });
258
333
  }
259
334
  else {
335
+ if (!this.isSettledBranchFailure(issue, event, project)) {
336
+ this.feed?.publish({
337
+ level: "info",
338
+ kind: "github",
339
+ issueKey: issue.issueKey,
340
+ projectId: issue.projectId,
341
+ stage: issue.factoryState,
342
+ status: "ci_waiting_for_settlement",
343
+ summary: `Waiting for settled ${this.getPrimaryGateCheckName(project)} result before starting CI repair`,
344
+ });
345
+ return;
346
+ }
260
347
  const failureContext = await this.resolveBranchFailureContext(issue, event, project);
261
348
  if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
262
349
  return;
263
350
  }
351
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
264
352
  this.db.upsertIssue({
265
353
  projectId: issue.projectId,
266
354
  linearIssueId: issue.linearIssueId,
@@ -268,6 +356,7 @@ export class GitHubWebhookHandler {
268
356
  pendingRunContextJson: JSON.stringify({
269
357
  ...failureContext,
270
358
  checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
359
+ ...(snapshot ? { ciSnapshot: snapshot } : {}),
271
360
  }),
272
361
  lastGitHubFailureSource: "branch_ci",
273
362
  lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
@@ -307,12 +396,14 @@ export class GitHubWebhookHandler {
307
396
  }
308
397
  }
309
398
  async updateFailureProvenance(issue, event, project) {
310
- const protocol = resolveMergeQueueProtocol(project);
311
- const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
399
+ const isQueueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
312
400
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
313
- const source = issue.factoryState === "awaiting_queue" && isQueueEvictionCheck
401
+ const source = isQueueEvictionCheck
314
402
  ? "queue_eviction"
315
403
  : "branch_ci";
404
+ if (source === "branch_ci" && !this.isSettledBranchFailure(issue, event, project)) {
405
+ return;
406
+ }
316
407
  const failureContext = source === "queue_eviction"
317
408
  ? this.buildQueueFailureContext(issue, event)
318
409
  : await this.resolveBranchFailureContext(issue, event, project);
@@ -337,9 +428,12 @@ export class GitHubWebhookHandler {
337
428
  });
338
429
  return;
339
430
  }
340
- if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck))
431
+ if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionCheck || this.isGateCheckEvent(event, project)))
341
432
  || event.triggerEvent === "pr_synchronize"
342
433
  || event.triggerEvent === "pr_merged") {
434
+ if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
435
+ return;
436
+ }
343
437
  this.db.upsertIssue({
344
438
  projectId: issue.projectId,
345
439
  linearIssueId: issue.linearIssueId,
@@ -358,10 +452,19 @@ export class GitHubWebhookHandler {
358
452
  }
359
453
  async resolveBranchFailureContext(issue, event, project) {
360
454
  const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
455
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
456
+ const primaryFailedCheck = snapshot ? this.pickPrimaryFailedCheck(snapshot) : undefined;
361
457
  const context = await this.failureContextResolver.resolve({
362
458
  source: "branch_ci",
363
459
  repoFullName,
364
- event,
460
+ event: primaryFailedCheck
461
+ ? {
462
+ ...event,
463
+ checkName: primaryFailedCheck.name,
464
+ checkUrl: primaryFailedCheck.detailsUrl ?? event.checkUrl,
465
+ checkDetailsUrl: primaryFailedCheck.detailsUrl ?? event.checkDetailsUrl,
466
+ }
467
+ : event,
365
468
  });
366
469
  return {
367
470
  ...(context ? context : {}),
@@ -433,6 +536,65 @@ export class GitHubWebhookHandler {
433
536
  }
434
537
  return false;
435
538
  }
539
+ getGateCheckNames(project) {
540
+ const configured = (project?.gateChecks ?? []).map((entry) => entry.trim()).filter(Boolean);
541
+ return configured.length > 0 ? configured : ["Tests"];
542
+ }
543
+ getPrimaryGateCheckName(project) {
544
+ return this.getGateCheckNames(project)[0] ?? "Tests";
545
+ }
546
+ isGateCheckEvent(event, project) {
547
+ if (event.eventSource !== "check_run" || !event.checkName)
548
+ return false;
549
+ const normalized = event.checkName.trim().toLowerCase();
550
+ return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
551
+ }
552
+ isStaleGateEvent(issue, event) {
553
+ return Boolean(issue.lastGitHubCiSnapshotHeadSha
554
+ && event.headSha
555
+ && issue.lastGitHubCiSnapshotHeadSha !== event.headSha);
556
+ }
557
+ isQueueEvictionFailure(issue, event, project) {
558
+ const protocol = resolveMergeQueueProtocol(project);
559
+ return issue.factoryState === "awaiting_queue"
560
+ && event.eventSource === "check_run"
561
+ && event.checkName === protocol.evictionCheckName;
562
+ }
563
+ isSettledBranchFailure(issue, event, project) {
564
+ if (event.triggerEvent !== "check_failed" || issue.prState !== "open")
565
+ return false;
566
+ if (!this.isGateCheckEvent(event, project))
567
+ return false;
568
+ const snapshot = this.getRelevantCiSnapshot(issue, event);
569
+ return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
570
+ }
571
+ canClearFailureProvenance(issue, event, project) {
572
+ if (event.triggerEvent !== "check_passed")
573
+ return true;
574
+ if (this.isQueueEvictionFailure(issue, event, project)) {
575
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
576
+ }
577
+ if (!this.isGateCheckEvent(event, project)) {
578
+ return true;
579
+ }
580
+ if (this.isStaleGateEvent(issue, event)) {
581
+ return false;
582
+ }
583
+ return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
584
+ }
585
+ getRelevantCiSnapshot(issue, event) {
586
+ const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
587
+ if (!snapshot)
588
+ return undefined;
589
+ if (snapshot.headSha !== event.headSha)
590
+ return undefined;
591
+ return snapshot;
592
+ }
593
+ pickPrimaryFailedCheck(snapshot) {
594
+ const gateName = snapshot.gateCheckName?.trim().toLowerCase();
595
+ return snapshot.failedChecks.find((entry) => entry.name.trim().toLowerCase() !== gateName)
596
+ ?? snapshot.failedChecks[0];
597
+ }
436
598
  async emitLinearActivity(issue, newState, event) {
437
599
  if (!issue.agentSessionId)
438
600
  return;
@@ -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. Diagnose the whole snapshot, fix the root cause and directly related fallout, 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
+ ? `All failed checks in settled snapshot:\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
+ : "", "", "Read the latest CI logs, consider the broader PR context, fix the likely root cause and any directly related fallout in one pass, run verification, 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;
package/dist/service.js CHANGED
@@ -436,7 +436,7 @@ export class PatchRelayService {
436
436
  // Infer run type from current state instead of always resetting to implementation
437
437
  let runType = "implementation";
438
438
  let factoryState = "delegated";
439
- if (issue.prNumber && issue.prCheckStatus === "failed") {
439
+ if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
440
440
  runType = "ci_repair";
441
441
  factoryState = "repairing_ci";
442
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {