patchrelay 0.30.1 → 0.31.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.30.1",
4
- "commit": "dee1d8b522b1",
5
- "builtAt": "2026-04-01T08:31:08.208Z"
3
+ "version": "0.31.0",
4
+ "commit": "5de73a74995a",
5
+ "builtAt": "2026-04-01T09:38:32.968Z"
6
6
  }
@@ -44,5 +44,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
44
44
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
45
45
  const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
46
  const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
48
48
  }
@@ -54,6 +54,11 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
54
54
  ciRepairAttempts: typeof i.ciRepairAttempts === "number" ? i.ciRepairAttempts : 0,
55
55
  queueRepairAttempts: typeof i.queueRepairAttempts === "number" ? i.queueRepairAttempts : 0,
56
56
  reviewFixAttempts: typeof i.reviewFixAttempts === "number" ? i.reviewFixAttempts : 0,
57
+ latestFailureSource: typeof i.latestFailureSource === "string" ? i.latestFailureSource : undefined,
58
+ latestFailureHeadSha: typeof i.latestFailureHeadSha === "string" ? i.latestFailureHeadSha : undefined,
59
+ latestFailureCheckName: typeof i.latestFailureCheckName === "string" ? i.latestFailureCheckName : undefined,
60
+ latestFailureStepName: typeof i.latestFailureStepName === "string" ? i.latestFailureStepName : undefined,
61
+ latestFailureSummary: typeof i.latestFailureSummary === "string" ? i.latestFailureSummary : undefined,
57
62
  runCount: runs.length,
58
63
  };
59
64
  }
@@ -171,6 +171,20 @@ function applyFeedEvent(state, event, receivedAt) {
171
171
  if (event.status === "check_passed" || event.status === "check_failed") {
172
172
  issue.prCheckStatus = event.status === "check_passed" ? "passed" : "failed";
173
173
  }
174
+ if (event.status === "ci_repair_queued") {
175
+ issue.factoryState = "repairing_ci";
176
+ issue.statusNote = event.detail ?? event.summary;
177
+ }
178
+ if (event.status === "queue_repair_queued") {
179
+ issue.factoryState = "repairing_queue";
180
+ issue.statusNote = event.detail ?? event.summary;
181
+ }
182
+ if (event.status === "repair_deduped" || event.status === "branch_not_advanced") {
183
+ issue.statusNote = event.summary;
184
+ }
185
+ }
186
+ if ((event.kind === "turn" || event.kind === "github") && event.status === "branch_not_advanced") {
187
+ issue.statusNote = event.summary;
174
188
  }
175
189
  issue.updatedAt = event.at;
176
190
  updated[index] = issue;
@@ -198,11 +198,16 @@ export function runPatchRelayMigrations(connection) {
198
198
  // Preserve GitHub failure provenance so reconciliation can distinguish
199
199
  // branch CI failures from merge-queue evictions after webhook delivery.
200
200
  addColumnIfMissing(connection, "issues", "last_github_failure_source", "TEXT");
201
+ addColumnIfMissing(connection, "issues", "last_github_failure_head_sha", "TEXT");
202
+ addColumnIfMissing(connection, "issues", "last_github_failure_signature", "TEXT");
201
203
  addColumnIfMissing(connection, "issues", "last_github_failure_check_name", "TEXT");
202
204
  addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
205
+ addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
203
206
  addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
204
207
  addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
205
208
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
209
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
210
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
206
211
  }
207
212
  function addColumnIfMissing(connection, table, column, definition) {
208
213
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
1
2
  import { LinearInstallationStore } from "./db/linear-installation-store.js";
2
3
  import { OperatorFeedStore } from "./db/operator-feed-store.js";
3
4
  import { RepositoryLinkStore } from "./db/repository-link-store.js";
@@ -164,6 +165,14 @@ export class PatchRelayDatabase {
164
165
  sets.push("last_github_failure_source = @lastGitHubFailureSource");
165
166
  values.lastGitHubFailureSource = params.lastGitHubFailureSource;
166
167
  }
168
+ if (params.lastGitHubFailureHeadSha !== undefined) {
169
+ sets.push("last_github_failure_head_sha = @lastGitHubFailureHeadSha");
170
+ values.lastGitHubFailureHeadSha = params.lastGitHubFailureHeadSha;
171
+ }
172
+ if (params.lastGitHubFailureSignature !== undefined) {
173
+ sets.push("last_github_failure_signature = @lastGitHubFailureSignature");
174
+ values.lastGitHubFailureSignature = params.lastGitHubFailureSignature;
175
+ }
167
176
  if (params.lastGitHubFailureCheckName !== undefined) {
168
177
  sets.push("last_github_failure_check_name = @lastGitHubFailureCheckName");
169
178
  values.lastGitHubFailureCheckName = params.lastGitHubFailureCheckName;
@@ -172,6 +181,10 @@ export class PatchRelayDatabase {
172
181
  sets.push("last_github_failure_check_url = @lastGitHubFailureCheckUrl");
173
182
  values.lastGitHubFailureCheckUrl = params.lastGitHubFailureCheckUrl;
174
183
  }
184
+ if (params.lastGitHubFailureContextJson !== undefined) {
185
+ sets.push("last_github_failure_context_json = @lastGitHubFailureContextJson");
186
+ values.lastGitHubFailureContextJson = params.lastGitHubFailureContextJson;
187
+ }
175
188
  if (params.lastGitHubFailureAt !== undefined) {
176
189
  sets.push("last_github_failure_at = @lastGitHubFailureAt");
177
190
  values.lastGitHubFailureAt = params.lastGitHubFailureAt;
@@ -184,6 +197,14 @@ export class PatchRelayDatabase {
184
197
  sets.push("last_queue_incident_json = @lastQueueIncidentJson");
185
198
  values.lastQueueIncidentJson = params.lastQueueIncidentJson;
186
199
  }
200
+ if (params.lastAttemptedFailureHeadSha !== undefined) {
201
+ sets.push("last_attempted_failure_head_sha = @lastAttemptedFailureHeadSha");
202
+ values.lastAttemptedFailureHeadSha = params.lastAttemptedFailureHeadSha;
203
+ }
204
+ if (params.lastAttemptedFailureSignature !== undefined) {
205
+ sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
206
+ values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
207
+ }
187
208
  if (params.ciRepairAttempts !== undefined) {
188
209
  sets.push("ci_repair_attempts = @ciRepairAttempts");
189
210
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -215,7 +236,8 @@ export class PatchRelayDatabase {
215
236
  branch_name, worktree_path, thread_id, active_run_id,
216
237
  agent_session_id,
217
238
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
218
- last_github_failure_source, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_at, last_queue_signal_at, last_queue_incident_json,
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,
240
+ last_attempted_failure_head_sha, last_attempted_failure_signature,
219
241
  updated_at
220
242
  ) VALUES (
221
243
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
@@ -224,7 +246,8 @@ export class PatchRelayDatabase {
224
246
  @branchName, @worktreePath, @threadId, @activeRunId,
225
247
  @agentSessionId,
226
248
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
227
- @lastGitHubFailureSource, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
249
+ @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
250
+ @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
228
251
  @now
229
252
  )
230
253
  `).run({
@@ -252,11 +275,16 @@ export class PatchRelayDatabase {
252
275
  prReviewState: params.prReviewState ?? null,
253
276
  prCheckStatus: params.prCheckStatus ?? null,
254
277
  lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
278
+ lastGitHubFailureHeadSha: params.lastGitHubFailureHeadSha ?? null,
279
+ lastGitHubFailureSignature: params.lastGitHubFailureSignature ?? null,
255
280
  lastGitHubFailureCheckName: params.lastGitHubFailureCheckName ?? null,
256
281
  lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
282
+ lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
257
283
  lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
258
284
  lastQueueSignalAt: params.lastQueueSignalAt ?? null,
259
285
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
286
+ lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
287
+ lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
260
288
  now,
261
289
  });
262
290
  }
@@ -508,6 +536,7 @@ export class PatchRelayDatabase {
508
536
  issueToTrackedIssue(issue) {
509
537
  const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
510
538
  const unresolvedBlockedBy = blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
539
+ const failureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
511
540
  return {
512
541
  id: issue.id,
513
542
  projectId: issue.projectId,
@@ -521,6 +550,11 @@ export class PatchRelayDatabase {
521
550
  blockedByKeys: unresolvedBlockedBy
522
551
  .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
523
552
  readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined && unresolvedBlockedBy.length === 0,
553
+ ...(issue.lastGitHubFailureSource ? { latestFailureSource: issue.lastGitHubFailureSource } : {}),
554
+ ...(issue.lastGitHubFailureHeadSha ? { latestFailureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
555
+ ...(issue.lastGitHubFailureCheckName ? { latestFailureCheckName: issue.lastGitHubFailureCheckName } : {}),
556
+ ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
557
+ ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
524
558
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
525
559
  ...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
526
560
  updatedAt: issue.updatedAt,
@@ -580,12 +614,21 @@ function mapIssueRow(row) {
580
614
  ...(row.last_github_failure_source !== null && row.last_github_failure_source !== undefined
581
615
  ? { lastGitHubFailureSource: String(row.last_github_failure_source) }
582
616
  : {}),
617
+ ...(row.last_github_failure_head_sha !== null && row.last_github_failure_head_sha !== undefined
618
+ ? { lastGitHubFailureHeadSha: String(row.last_github_failure_head_sha) }
619
+ : {}),
620
+ ...(row.last_github_failure_signature !== null && row.last_github_failure_signature !== undefined
621
+ ? { lastGitHubFailureSignature: String(row.last_github_failure_signature) }
622
+ : {}),
583
623
  ...(row.last_github_failure_check_name !== null && row.last_github_failure_check_name !== undefined
584
624
  ? { lastGitHubFailureCheckName: String(row.last_github_failure_check_name) }
585
625
  : {}),
586
626
  ...(row.last_github_failure_check_url !== null && row.last_github_failure_check_url !== undefined
587
627
  ? { lastGitHubFailureCheckUrl: String(row.last_github_failure_check_url) }
588
628
  : {}),
629
+ ...(row.last_github_failure_context_json !== null && row.last_github_failure_context_json !== undefined
630
+ ? { lastGitHubFailureContextJson: String(row.last_github_failure_context_json) }
631
+ : {}),
589
632
  ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
590
633
  ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
591
634
  : {}),
@@ -595,6 +638,12 @@ function mapIssueRow(row) {
595
638
  ...(row.last_queue_incident_json !== null && row.last_queue_incident_json !== undefined
596
639
  ? { lastQueueIncidentJson: String(row.last_queue_incident_json) }
597
640
  : {}),
641
+ ...(row.last_attempted_failure_head_sha !== null && row.last_attempted_failure_head_sha !== undefined
642
+ ? { lastAttemptedFailureHeadSha: String(row.last_attempted_failure_head_sha) }
643
+ : {}),
644
+ ...(row.last_attempted_failure_signature !== null && row.last_attempted_failure_signature !== undefined
645
+ ? { lastAttemptedFailureSignature: String(row.last_attempted_failure_signature) }
646
+ : {}),
598
647
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
599
648
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
600
649
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -0,0 +1,205 @@
1
+ import { execCommand, safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
2
+ const FAILED_CONCLUSIONS = new Set([
3
+ "failure",
4
+ "timed_out",
5
+ "cancelled",
6
+ "startup_failure",
7
+ "action_required",
8
+ "stale",
9
+ ]);
10
+ export function createGitHubFailureContextResolver() {
11
+ return {
12
+ resolve: async ({ source, repoFullName, event }) => {
13
+ if (!repoFullName)
14
+ return undefined;
15
+ if (source === "queue_eviction") {
16
+ const queueContext = buildFallbackFailureContext(source, repoFullName, event);
17
+ return {
18
+ ...queueContext,
19
+ failureSignature: buildFailureSignature({
20
+ source,
21
+ headSha: queueContext.headSha,
22
+ checkName: queueContext.checkName,
23
+ }),
24
+ };
25
+ }
26
+ const fallback = buildFallbackFailureContext(source, repoFullName, event);
27
+ try {
28
+ const failedCheck = await resolveFailedCheckRun(repoFullName, event);
29
+ const workflowRunId = parseWorkflowRunId(failedCheck?.detailsUrl ?? failedCheck?.htmlUrl ?? event.checkDetailsUrl ?? event.checkUrl);
30
+ const workflowJob = workflowRunId
31
+ ? await resolveWorkflowJob(repoFullName, workflowRunId, failedCheck?.name ?? event.checkName)
32
+ : undefined;
33
+ const annotations = failedCheck?.id
34
+ ? await resolveAnnotations(repoFullName, failedCheck.id)
35
+ : undefined;
36
+ const summary = firstNonEmpty(annotations?.[0], failedCheck?.outputTitle, failedCheck?.outputSummary, event.checkOutputTitle, event.checkOutputSummary, workflowJob?.stepName ? `Failed step: ${workflowJob.stepName}` : undefined);
37
+ const checkName = firstNonEmpty(failedCheck?.name, event.checkName);
38
+ const checkUrl = firstNonEmpty(failedCheck?.htmlUrl, event.checkUrl);
39
+ const checkDetailsUrl = firstNonEmpty(failedCheck?.detailsUrl, event.checkDetailsUrl);
40
+ const jobName = firstNonEmpty(workflowJob?.name, failedCheck?.name, event.checkName);
41
+ const stepName = workflowJob?.stepName;
42
+ return {
43
+ source,
44
+ repoFullName,
45
+ capturedAt: new Date().toISOString(),
46
+ ...(event.headSha ? { headSha: event.headSha } : {}),
47
+ ...(checkName ? { checkName } : {}),
48
+ ...(checkUrl ? { checkUrl } : {}),
49
+ ...(checkDetailsUrl ? { checkDetailsUrl } : {}),
50
+ ...(workflowRunId !== undefined ? { workflowRunId } : {}),
51
+ ...(jobName ? { jobName } : {}),
52
+ ...(stepName ? { stepName } : {}),
53
+ ...(summary ? { summary } : {}),
54
+ ...(annotations && annotations.length > 0 ? { annotations } : {}),
55
+ failureSignature: buildFailureSignature({
56
+ source,
57
+ headSha: event.headSha,
58
+ checkName,
59
+ jobName,
60
+ stepName,
61
+ }),
62
+ };
63
+ }
64
+ catch {
65
+ return {
66
+ ...fallback,
67
+ failureSignature: buildFailureSignature({
68
+ source,
69
+ headSha: fallback.headSha,
70
+ checkName: fallback.checkName,
71
+ stepName: fallback.stepName,
72
+ }),
73
+ };
74
+ }
75
+ },
76
+ };
77
+ }
78
+ export function parseGitHubFailureContext(value) {
79
+ if (!value)
80
+ return undefined;
81
+ return safeJsonParse(value);
82
+ }
83
+ export function summarizeGitHubFailureContext(context) {
84
+ if (!context)
85
+ return undefined;
86
+ if (context.source === "queue_eviction") {
87
+ return firstNonEmpty(context.summary, context.checkName, "Queue eviction");
88
+ }
89
+ const lead = firstNonEmpty(context.jobName, context.checkName);
90
+ const step = context.stepName ? `${lead ?? "CI"} -> ${context.stepName}` : lead;
91
+ return firstNonEmpty(step && context.summary ? `${step}: ${context.summary}` : undefined, step, context.summary);
92
+ }
93
+ function buildFallbackFailureContext(source, repoFullName, event) {
94
+ const summary = firstNonEmpty(event.checkOutputTitle, event.checkOutputSummary, event.checkOutputText ? sanitizeDiagnosticText(event.checkOutputText, 240) : undefined);
95
+ return {
96
+ source,
97
+ repoFullName,
98
+ capturedAt: new Date().toISOString(),
99
+ ...(event.headSha ? { headSha: event.headSha } : {}),
100
+ ...(event.checkName ? { checkName: event.checkName } : {}),
101
+ ...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
102
+ ...(event.checkDetailsUrl ? { checkDetailsUrl: event.checkDetailsUrl } : {}),
103
+ ...(event.checkName ? { jobName: event.checkName } : {}),
104
+ ...(summary ? { summary } : {}),
105
+ };
106
+ }
107
+ async function resolveFailedCheckRun(repoFullName, event) {
108
+ if (!event.headSha)
109
+ return undefined;
110
+ const response = await execCommand("gh", [
111
+ "api",
112
+ `repos/${repoFullName}/commits/${event.headSha}/check-runs`,
113
+ "--method", "GET",
114
+ ], { timeoutMs: 15_000 });
115
+ if (response.exitCode !== 0) {
116
+ throw new Error(response.stderr || "gh api check-runs failed");
117
+ }
118
+ const payload = safeJsonParse(response.stdout);
119
+ const checks = (payload?.check_runs ?? [])
120
+ .map(mapCheckRunSummary)
121
+ .filter((entry) => entry.conclusion && FAILED_CONCLUSIONS.has(entry.conclusion.toLowerCase()));
122
+ return checks.find((entry) => entry.name === event.checkName)
123
+ ?? checks.find((entry) => entry.name && event.checkName && entry.name.includes(event.checkName))
124
+ ?? checks[0];
125
+ }
126
+ async function resolveWorkflowJob(repoFullName, workflowRunId, preferredName) {
127
+ const response = await execCommand("gh", [
128
+ "api",
129
+ `repos/${repoFullName}/actions/runs/${workflowRunId}/jobs`,
130
+ "--method", "GET",
131
+ ], { timeoutMs: 15_000 });
132
+ if (response.exitCode !== 0) {
133
+ throw new Error(response.stderr || "gh api workflow jobs failed");
134
+ }
135
+ const payload = safeJsonParse(response.stdout);
136
+ const jobs = (payload?.jobs ?? []).map(mapWorkflowJobSummary);
137
+ return jobs.find((entry) => entry.name === preferredName)
138
+ ?? jobs.find((entry) => entry.name && preferredName && entry.name.includes(preferredName))
139
+ ?? jobs.find((entry) => entry.conclusion && FAILED_CONCLUSIONS.has(entry.conclusion.toLowerCase()))
140
+ ?? jobs[0];
141
+ }
142
+ async function resolveAnnotations(repoFullName, checkRunId) {
143
+ const response = await execCommand("gh", [
144
+ "api",
145
+ `repos/${repoFullName}/check-runs/${checkRunId}/annotations`,
146
+ "--method", "GET",
147
+ "-F", "per_page=20",
148
+ ], { timeoutMs: 15_000 });
149
+ if (response.exitCode !== 0) {
150
+ throw new Error(response.stderr || "gh api annotations failed");
151
+ }
152
+ const payload = safeJsonParse(response.stdout) ?? [];
153
+ return payload
154
+ .map((entry) => {
155
+ const title = typeof entry.title === "string" ? entry.title.trim() : "";
156
+ const message = typeof entry.message === "string" ? entry.message.trim() : "";
157
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
158
+ const rendered = [title, message, path ? `(${path})` : ""].filter(Boolean).join(": ");
159
+ return rendered ? sanitizeDiagnosticText(rendered, 240) : undefined;
160
+ })
161
+ .filter((entry) => Boolean(entry));
162
+ }
163
+ function mapCheckRunSummary(row) {
164
+ const output = row.output && typeof row.output === "object" ? row.output : undefined;
165
+ return {
166
+ ...(typeof row.id === "number" ? { id: row.id } : {}),
167
+ ...(typeof row.name === "string" ? { name: row.name } : {}),
168
+ ...(typeof row.html_url === "string" ? { htmlUrl: row.html_url } : {}),
169
+ ...(typeof row.details_url === "string" ? { detailsUrl: row.details_url } : {}),
170
+ ...(typeof row.conclusion === "string" ? { conclusion: row.conclusion } : {}),
171
+ ...(typeof output?.title === "string" ? { outputTitle: output.title } : {}),
172
+ ...(typeof output?.summary === "string" ? { outputSummary: sanitizeDiagnosticText(output.summary, 240) } : {}),
173
+ ...(typeof output?.text === "string" ? { outputText: sanitizeDiagnosticText(output.text, 240) } : {}),
174
+ };
175
+ }
176
+ function mapWorkflowJobSummary(row) {
177
+ const steps = Array.isArray(row.steps) ? row.steps.filter((entry) => Boolean(entry) && typeof entry === "object") : [];
178
+ const failedStep = steps.find((entry) => {
179
+ const conclusion = typeof entry.conclusion === "string" ? entry.conclusion.toLowerCase() : "";
180
+ return FAILED_CONCLUSIONS.has(conclusion);
181
+ });
182
+ const informativeStep = failedStep ?? steps.findLast((entry) => typeof entry.name === "string");
183
+ return {
184
+ ...(typeof row.name === "string" ? { name: row.name } : {}),
185
+ ...(typeof row.conclusion === "string" ? { conclusion: row.conclusion } : {}),
186
+ ...(typeof informativeStep?.name === "string" ? { stepName: informativeStep.name } : {}),
187
+ };
188
+ }
189
+ function parseWorkflowRunId(url) {
190
+ if (!url)
191
+ return undefined;
192
+ const match = url.match(/\/actions\/runs\/(\d+)/);
193
+ return match ? Number(match[1]) : undefined;
194
+ }
195
+ function buildFailureSignature(parts) {
196
+ return [
197
+ parts.source,
198
+ parts.headSha ?? "unknown-sha",
199
+ parts.jobName ?? parts.checkName ?? "unknown-check",
200
+ parts.stepName ?? "unknown-step",
201
+ ].join("::");
202
+ }
203
+ function firstNonEmpty(...values) {
204
+ return values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
205
+ }
@@ -1,4 +1,5 @@
1
1
  import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
2
+ import { createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
2
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
4
5
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
@@ -28,7 +29,8 @@ export class GitHubWebhookHandler {
28
29
  logger;
29
30
  codex;
30
31
  feed;
31
- constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed) {
32
+ failureContextResolver;
33
+ constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver()) {
32
34
  this.config = config;
33
35
  this.db = db;
34
36
  this.linearProvider = linearProvider;
@@ -36,6 +38,7 @@ export class GitHubWebhookHandler {
36
38
  this.logger = logger;
37
39
  this.codex = codex;
38
40
  this.feed = feed;
41
+ this.failureContextResolver = failureContextResolver;
39
42
  }
40
43
  async acceptGitHubWebhook(params) {
41
44
  // Deduplicate
@@ -114,6 +117,7 @@ export class GitHubWebhookHandler {
114
117
  this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
115
118
  return;
116
119
  }
120
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
117
121
  // Update PR state on the issue
118
122
  this.db.upsertIssue({
119
123
  projectId: issue.projectId,
@@ -124,7 +128,7 @@ export class GitHubWebhookHandler {
124
128
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
125
129
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
126
130
  });
127
- this.updateFailureProvenance(issue, event);
131
+ await this.updateFailureProvenance(issue, event, project);
128
132
  if (!isMetadataOnlyCheckEvent(event)) {
129
133
  // Re-read issue after PR metadata upsert so guards see fresh prReviewState
130
134
  const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -168,10 +172,15 @@ export class GitHubWebhookHandler {
168
172
  ciRepairAttempts: 0,
169
173
  queueRepairAttempts: 0,
170
174
  lastGitHubFailureSource: null,
175
+ lastGitHubFailureHeadSha: null,
176
+ lastGitHubFailureSignature: null,
171
177
  lastGitHubFailureCheckName: null,
172
178
  lastGitHubFailureCheckUrl: null,
179
+ lastGitHubFailureContextJson: null,
173
180
  lastGitHubFailureAt: null,
174
181
  lastQueueIncidentJson: null,
182
+ lastAttemptedFailureHeadSha: null,
183
+ lastAttemptedFailureSignature: null,
175
184
  });
176
185
  }
177
186
  this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
@@ -188,16 +197,15 @@ export class GitHubWebhookHandler {
188
197
  // Queue eviction check runs bypass the metadata-only filter because
189
198
  // they're individual check_run events (not check_suite), but they
190
199
  // must drive state transitions.
191
- const project = this.config.projects.find((p) => p.id === freshIssue.projectId);
192
200
  const protocol = resolveMergeQueueProtocol(project);
193
201
  if (event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName) {
194
- this.maybeEnqueueReactiveRun(freshIssue, event, project);
202
+ await this.maybeEnqueueReactiveRun(freshIssue, event, project);
195
203
  }
196
204
  else if (!isMetadataOnlyCheckEvent(event)) {
197
- this.maybeEnqueueReactiveRun(freshIssue, event, project);
205
+ await this.maybeEnqueueReactiveRun(freshIssue, event, project);
198
206
  }
199
207
  }
200
- maybeEnqueueReactiveRun(issue, event, project) {
208
+ async maybeEnqueueReactiveRun(issue, event, project) {
201
209
  // Don't trigger if there's already an active run
202
210
  if (issue.activeRunId !== undefined)
203
211
  return;
@@ -213,14 +221,24 @@ export class GitHubWebhookHandler {
213
221
  if (issue.factoryState === "awaiting_queue"
214
222
  && event.checkName === queueCheckName) {
215
223
  const queueRepairContext = buildQueueRepairContextFromEvent(event);
224
+ const failureContext = this.buildQueueFailureContext(issue, event, queueRepairContext);
225
+ if (this.hasDuplicatePendingReactiveRun(issue, "queue_repair", failureContext)) {
226
+ return;
227
+ }
216
228
  this.db.upsertIssue({
217
229
  projectId: issue.projectId,
218
230
  linearIssueId: issue.linearIssueId,
219
231
  pendingRunType: "queue_repair",
220
- pendingRunContextJson: JSON.stringify(queueRepairContext),
232
+ pendingRunContextJson: JSON.stringify({
233
+ ...queueRepairContext,
234
+ ...failureContext,
235
+ }),
221
236
  lastGitHubFailureSource: "queue_eviction",
237
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
238
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
222
239
  lastGitHubFailureCheckName: event.checkName ?? null,
223
240
  lastGitHubFailureCheckUrl: event.checkUrl ?? null,
241
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
224
242
  lastGitHubFailureAt: new Date().toISOString(),
225
243
  lastQueueSignalAt: new Date().toISOString(),
226
244
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
@@ -239,23 +257,39 @@ export class GitHubWebhookHandler {
239
257
  });
240
258
  }
241
259
  else {
260
+ const failureContext = await this.resolveBranchFailureContext(issue, event, project);
261
+ if (this.hasDuplicatePendingReactiveRun(issue, "ci_repair", failureContext)) {
262
+ return;
263
+ }
242
264
  this.db.upsertIssue({
243
265
  projectId: issue.projectId,
244
266
  linearIssueId: issue.linearIssueId,
245
267
  pendingRunType: "ci_repair",
246
268
  pendingRunContextJson: JSON.stringify({
247
- checkName: event.checkName,
248
- checkUrl: event.checkUrl,
249
- checkClass: resolveCheckClass(event.checkName, project),
269
+ ...failureContext,
270
+ checkClass: resolveCheckClass(failureContext.checkName ?? event.checkName, project),
250
271
  }),
251
272
  lastGitHubFailureSource: "branch_ci",
252
- lastGitHubFailureCheckName: event.checkName ?? null,
253
- lastGitHubFailureCheckUrl: event.checkUrl ?? null,
273
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
274
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
275
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
276
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
277
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
254
278
  lastGitHubFailureAt: new Date().toISOString(),
255
279
  lastQueueIncidentJson: null,
256
280
  });
257
281
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
258
- this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Enqueued CI repair run");
282
+ this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
283
+ this.feed?.publish({
284
+ level: "warn",
285
+ kind: "github",
286
+ issueKey: issue.issueKey,
287
+ projectId: issue.projectId,
288
+ stage: "repairing_ci",
289
+ status: "ci_repair_queued",
290
+ summary: `CI repair queued for ${failureContext.jobName ?? failureContext.checkName ?? "failed check"}`,
291
+ detail: summarizeGitHubFailureContext(failureContext),
292
+ });
259
293
  }
260
294
  }
261
295
  if (event.triggerEvent === "review_changes_requested") {
@@ -272,23 +306,25 @@ export class GitHubWebhookHandler {
272
306
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
273
307
  }
274
308
  }
275
- updateFailureProvenance(issue, event) {
276
- const project = this.config.projects.find((p) => p.id === issue.projectId);
309
+ async updateFailureProvenance(issue, event, project) {
277
310
  const protocol = resolveMergeQueueProtocol(project);
278
311
  const isQueueEvictionCheck = event.eventSource === "check_run" && event.checkName === protocol.evictionCheckName;
279
312
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
280
- if (isMetadataOnlyCheckEvent(event) && !isQueueEvictionCheck) {
281
- return;
282
- }
283
313
  const source = issue.factoryState === "awaiting_queue" && isQueueEvictionCheck
284
314
  ? "queue_eviction"
285
315
  : "branch_ci";
316
+ const failureContext = source === "queue_eviction"
317
+ ? this.buildQueueFailureContext(issue, event)
318
+ : await this.resolveBranchFailureContext(issue, event, project);
286
319
  this.db.upsertIssue({
287
320
  projectId: issue.projectId,
288
321
  linearIssueId: issue.linearIssueId,
289
322
  lastGitHubFailureSource: source,
290
- lastGitHubFailureCheckName: event.checkName ?? null,
291
- lastGitHubFailureCheckUrl: event.checkUrl ?? null,
323
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
324
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
325
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
326
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
327
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
292
328
  lastGitHubFailureAt: new Date().toISOString(),
293
329
  ...(source === "queue_eviction"
294
330
  ? {
@@ -308,12 +344,94 @@ export class GitHubWebhookHandler {
308
344
  projectId: issue.projectId,
309
345
  linearIssueId: issue.linearIssueId,
310
346
  lastGitHubFailureSource: null,
347
+ lastGitHubFailureHeadSha: null,
348
+ lastGitHubFailureSignature: null,
311
349
  lastGitHubFailureCheckName: null,
312
350
  lastGitHubFailureCheckUrl: null,
351
+ lastGitHubFailureContextJson: null,
313
352
  lastGitHubFailureAt: null,
314
353
  lastQueueIncidentJson: null,
354
+ lastAttemptedFailureHeadSha: null,
355
+ lastAttemptedFailureSignature: null,
356
+ });
357
+ }
358
+ }
359
+ async resolveBranchFailureContext(issue, event, project) {
360
+ const repoFullName = project?.github?.repoFullName ?? event.repoFullName;
361
+ const context = await this.failureContextResolver.resolve({
362
+ source: "branch_ci",
363
+ repoFullName,
364
+ event,
365
+ });
366
+ return {
367
+ ...(context ? context : {}),
368
+ ...(context?.headSha || event.headSha ? { failureHeadSha: context?.headSha ?? event.headSha } : {}),
369
+ ...(context?.failureSignature ? { failureSignature: context.failureSignature } : {}),
370
+ };
371
+ }
372
+ buildQueueFailureContext(issue, event, queueRepairContext) {
373
+ const repoFullName = event.repoFullName || this.config.projects.find((p) => p.id === issue.projectId)?.github?.repoFullName || "";
374
+ const incident = queueRepairContext && typeof queueRepairContext === "object"
375
+ ? queueRepairContext
376
+ : undefined;
377
+ const summary = typeof incident?.incidentSummary === "string"
378
+ ? incident.incidentSummary
379
+ : event.checkOutputSummary ?? event.checkOutputTitle;
380
+ const failureHeadSha = event.headSha;
381
+ const failureSignature = [
382
+ "queue_eviction",
383
+ failureHeadSha ?? "unknown-sha",
384
+ event.checkName ?? "merge-steward/queue",
385
+ ].join("::");
386
+ return {
387
+ source: "queue_eviction",
388
+ repoFullName,
389
+ capturedAt: new Date().toISOString(),
390
+ ...(failureHeadSha ? { headSha: failureHeadSha, failureHeadSha } : {}),
391
+ ...(event.checkName ? { checkName: event.checkName } : {}),
392
+ ...(event.checkUrl ? { checkUrl: event.checkUrl } : {}),
393
+ ...(event.checkDetailsUrl ? { checkDetailsUrl: event.checkDetailsUrl } : {}),
394
+ ...(summary ? { summary } : {}),
395
+ failureSignature,
396
+ };
397
+ }
398
+ hasDuplicatePendingReactiveRun(issue, runType, failureContext) {
399
+ const signature = typeof failureContext.failureSignature === "string" ? failureContext.failureSignature : undefined;
400
+ const headSha = typeof failureContext.failureHeadSha === "string"
401
+ ? failureContext.failureHeadSha
402
+ : typeof failureContext.headSha === "string" ? failureContext.headSha : undefined;
403
+ if (!signature)
404
+ return false;
405
+ if (issue.pendingRunType === runType && issue.pendingRunContextJson) {
406
+ const existing = safeJsonParse(issue.pendingRunContextJson);
407
+ if (existing?.failureSignature === signature
408
+ && (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
409
+ this.feed?.publish({
410
+ level: "info",
411
+ kind: "github",
412
+ issueKey: issue.issueKey,
413
+ projectId: issue.projectId,
414
+ stage: issue.factoryState,
415
+ status: "repair_deduped",
416
+ summary: `Skipped duplicate ${runType} for ${signature}`,
417
+ });
418
+ return true;
419
+ }
420
+ }
421
+ if (issue.lastAttemptedFailureSignature === signature
422
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha)) {
423
+ this.feed?.publish({
424
+ level: "info",
425
+ kind: "github",
426
+ issueKey: issue.issueKey,
427
+ projectId: issue.projectId,
428
+ stage: issue.factoryState,
429
+ status: "repair_deduped",
430
+ summary: `Already attempted ${runType} for this failing PR head`,
315
431
  });
432
+ return true;
316
433
  }
434
+ return false;
317
435
  }
318
436
  async emitLinearActivity(issue, newState, event) {
319
437
  if (!issue.agentSessionId)
@@ -421,9 +539,9 @@ export class GitHubWebhookHandler {
421
539
  function resolveCheckClass(checkName, project) {
422
540
  if (!checkName || !project)
423
541
  return "code";
424
- if (project.reviewChecks.some((name) => checkName.includes(name)))
542
+ if ((project.reviewChecks ?? []).some((name) => checkName.includes(name)))
425
543
  return "review";
426
- if (project.gateChecks.some((name) => checkName.includes(name)))
544
+ if ((project.gateChecks ?? []).some((name) => checkName.includes(name)))
427
545
  return "gate";
428
546
  return "code";
429
547
  }
@@ -1,3 +1,4 @@
1
+ import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
1
2
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
2
3
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
4
  import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
@@ -149,6 +150,7 @@ export class IssueQueryService {
149
150
  buildQueueProtocol(projectId, issue) {
150
151
  const project = this.config.projects.find((entry) => entry.id === projectId);
151
152
  const protocol = resolveMergeQueueProtocol(project);
153
+ const failureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
152
154
  const queueIncident = issue.lastQueueIncidentJson
153
155
  ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
154
156
  : undefined;
@@ -159,8 +161,12 @@ export class IssueQueryService {
159
161
  evictionCheckName: protocol.evictionCheckName,
160
162
  prNumber: issue.prNumber ?? null,
161
163
  lastFailureSource: issue.lastGitHubFailureSource ?? null,
164
+ lastFailureHeadSha: issue.lastGitHubFailureHeadSha ?? failureContext?.headSha ?? null,
165
+ lastFailureSignature: issue.lastGitHubFailureSignature ?? failureContext?.failureSignature ?? null,
162
166
  lastFailureCheckName: issue.lastGitHubFailureCheckName ?? null,
163
167
  lastFailureCheckUrl: issue.lastGitHubFailureCheckUrl ?? null,
168
+ lastFailureStepName: failureContext?.stepName ?? null,
169
+ lastFailureSummary: summarizeGitHubFailureContext(failureContext) ?? null,
164
170
  lastFailureAt: issue.lastGitHubFailureAt ?? null,
165
171
  lastQueueSignalAt: issue.lastQueueSignalAt ?? null,
166
172
  lastIncidentId: queueIncident?.incidentId ?? null,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
4
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
4
5
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
5
6
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
6
7
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
@@ -57,7 +58,9 @@ function buildRunPrompt(issue, runType, repoPath, context) {
57
58
  // Add run-type-specific context for reactive runs
58
59
  switch (runType) {
59
60
  case "ci_repair":
60
- lines.push("## CI Repair", "", "A CI check has failed on your PR. Fix the failure and push.", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", "", "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.", "");
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
62
+ ? `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.", "");
61
64
  break;
62
65
  case "review_fix":
63
66
  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.", "");
@@ -155,6 +158,10 @@ export class RunOrchestrator {
155
158
  runType,
156
159
  promptText: prompt,
157
160
  });
161
+ const failureHeadSha = typeof context?.failureHeadSha === "string"
162
+ ? context.failureHeadSha
163
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
164
+ const failureSignature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
158
165
  this.db.upsertIssue({
159
166
  projectId: item.projectId,
160
167
  linearIssueId: item.issueId,
@@ -168,6 +175,12 @@ export class RunOrchestrator {
168
175
  : runType === "review_fix" ? "changes_requested"
169
176
  : runType === "queue_repair" ? "repairing_queue"
170
177
  : "implementing",
178
+ ...((runType === "ci_repair" || runType === "queue_repair") && failureSignature
179
+ ? {
180
+ lastAttemptedFailureSignature: failureSignature,
181
+ lastAttemptedFailureHeadSha: failureHeadSha ?? null,
182
+ }
183
+ : {}),
171
184
  });
172
185
  return created;
173
186
  });
@@ -402,6 +415,26 @@ export class RunOrchestrator {
402
415
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
403
416
  // Determine post-run state based on current PR metadata.
404
417
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
418
+ const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
419
+ if (verifiedRepairError) {
420
+ const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
421
+ this.failRunAndClear(run, verifiedRepairError, holdState);
422
+ const heldIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
423
+ this.feed?.publish({
424
+ level: "warn",
425
+ kind: "turn",
426
+ issueKey: freshIssue.issueKey,
427
+ projectId: run.projectId,
428
+ stage: run.runType,
429
+ status: "branch_not_advanced",
430
+ summary: verifiedRepairError,
431
+ });
432
+ void this.emitLinearActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
433
+ void this.syncLinearSession(heldIssue, { activeRunType: run.runType });
434
+ this.progressThrottle.delete(run.id);
435
+ this.activeThreadId = undefined;
436
+ return;
437
+ }
405
438
  const postRunState = resolvePostRunState(freshIssue);
406
439
  this.db.transaction(() => {
407
440
  this.db.finishRun(run.id, {
@@ -419,10 +452,15 @@ export class RunOrchestrator {
419
452
  ...(postRunState === "awaiting_queue" || postRunState === "done"
420
453
  ? {
421
454
  lastGitHubFailureSource: null,
455
+ lastGitHubFailureHeadSha: null,
456
+ lastGitHubFailureSignature: null,
422
457
  lastGitHubFailureCheckName: null,
423
458
  lastGitHubFailureCheckUrl: null,
459
+ lastGitHubFailureContextJson: null,
424
460
  lastGitHubFailureAt: null,
425
461
  lastQueueIncidentJson: null,
462
+ lastAttemptedFailureHeadSha: null,
463
+ lastAttemptedFailureSignature: null,
426
464
  }
427
465
  : {}),
428
466
  });
@@ -534,8 +572,11 @@ export class RunOrchestrator {
534
572
  // Checks failed + idle — route based on durable GitHub failure provenance.
535
573
  if (issue.prCheckStatus === "failed") {
536
574
  if (issue.lastGitHubFailureSource === "queue_eviction") {
537
- if (issue.factoryState !== "repairing_queue") {
538
- const pendingRunContext = buildFailureContext(issue);
575
+ const pendingRunContext = buildFailureContext(issue);
576
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
577
+ this.advanceIdleIssue(issue, "repairing_queue");
578
+ }
579
+ else {
539
580
  this.advanceIdleIssue(issue, "repairing_queue", {
540
581
  pendingRunType: "queue_repair",
541
582
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -544,8 +585,11 @@ export class RunOrchestrator {
544
585
  continue;
545
586
  }
546
587
  if (issue.lastGitHubFailureSource === "branch_ci") {
547
- if (issue.factoryState !== "repairing_ci") {
548
- const pendingRunContext = buildFailureContext(issue);
588
+ const pendingRunContext = buildFailureContext(issue);
589
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
590
+ this.advanceIdleIssue(issue, "repairing_ci");
591
+ }
592
+ else {
549
593
  this.advanceIdleIssue(issue, "repairing_ci", {
550
594
  pendingRunType: "ci_repair",
551
595
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -566,8 +610,11 @@ export class RunOrchestrator {
566
610
  });
567
611
  continue;
568
612
  }
569
- if (issue.factoryState !== "repairing_ci") {
570
- const pendingRunContext = buildFailureContext(issue);
613
+ const pendingRunContext = buildFailureContext(issue);
614
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
615
+ this.advanceIdleIssue(issue, "repairing_ci");
616
+ }
617
+ else {
571
618
  this.advanceIdleIssue(issue, "repairing_ci", {
572
619
  pendingRunType: "ci_repair",
573
620
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -606,6 +653,9 @@ export class RunOrchestrator {
606
653
  }
607
654
  }
608
655
  advanceIdleIssue(issue, newState, options) {
656
+ if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
657
+ return;
658
+ }
609
659
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
610
660
  this.db.upsertIssue({
611
661
  projectId: issue.projectId,
@@ -620,10 +670,15 @@ export class RunOrchestrator {
620
670
  ...(options?.clearFailureProvenance
621
671
  ? {
622
672
  lastGitHubFailureSource: null,
673
+ lastGitHubFailureHeadSha: null,
674
+ lastGitHubFailureSignature: null,
623
675
  lastGitHubFailureCheckName: null,
624
676
  lastGitHubFailureCheckUrl: null,
677
+ lastGitHubFailureContextJson: null,
625
678
  lastGitHubFailureAt: null,
626
679
  lastQueueIncidentJson: null,
680
+ lastAttemptedFailureHeadSha: null,
681
+ lastAttemptedFailureSignature: null,
627
682
  }
628
683
  : {}),
629
684
  });
@@ -790,7 +845,7 @@ export class RunOrchestrator {
790
845
  else if (run.runType === "review_fix" && issue.reviewFixAttempts > 0) {
791
846
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts - 1 });
792
847
  }
793
- const recoveredState = resolvePostRunState(this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
848
+ const recoveredState = resolveRecoverablePostRunState(this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
794
849
  this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
795
850
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
796
851
  if (recoveredState) {
@@ -815,6 +870,21 @@ export class RunOrchestrator {
815
870
  const trackedIssue = this.db.issueToTrackedIssue(issue);
816
871
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
817
872
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
873
+ const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
874
+ if (verifiedRepairError) {
875
+ const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
876
+ this.failRunAndClear(run, verifiedRepairError, holdState);
877
+ this.feed?.publish({
878
+ level: "warn",
879
+ kind: "turn",
880
+ issueKey: issue.issueKey,
881
+ projectId: run.projectId,
882
+ stage: run.runType,
883
+ status: "branch_not_advanced",
884
+ summary: verifiedRepairError,
885
+ });
886
+ return;
887
+ }
818
888
  const postRunState = resolvePostRunState(freshIssue);
819
889
  this.db.transaction(() => {
820
890
  this.db.finishRun(run.id, {
@@ -832,10 +902,15 @@ export class RunOrchestrator {
832
902
  ...(postRunState === "awaiting_queue" || postRunState === "done"
833
903
  ? {
834
904
  lastGitHubFailureSource: null,
905
+ lastGitHubFailureHeadSha: null,
906
+ lastGitHubFailureSignature: null,
835
907
  lastGitHubFailureCheckName: null,
836
908
  lastGitHubFailureCheckUrl: null,
909
+ lastGitHubFailureContextJson: null,
837
910
  lastGitHubFailureAt: null,
838
911
  lastQueueIncidentJson: null,
912
+ lastAttemptedFailureHeadSha: null,
913
+ lastAttemptedFailureSignature: null,
839
914
  }
840
915
  : {}),
841
916
  });
@@ -908,6 +983,41 @@ export class RunOrchestrator {
908
983
  });
909
984
  });
910
985
  }
986
+ async verifyReactiveRunAdvancedBranch(run, issue) {
987
+ if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
988
+ return undefined;
989
+ }
990
+ if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
991
+ return undefined;
992
+ }
993
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
994
+ if (!project?.github?.repoFullName) {
995
+ return undefined;
996
+ }
997
+ try {
998
+ const { stdout, exitCode } = await execCommand("gh", [
999
+ "pr", "view", String(issue.prNumber),
1000
+ "--repo", project.github.repoFullName,
1001
+ "--json", "headRefOid,state",
1002
+ ], { timeoutMs: 10_000 });
1003
+ if (exitCode !== 0)
1004
+ return undefined;
1005
+ const pr = JSON.parse(stdout);
1006
+ if (pr.state?.toUpperCase() !== "OPEN")
1007
+ return undefined;
1008
+ if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
1009
+ return undefined;
1010
+ return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
1011
+ }
1012
+ catch (error) {
1013
+ this.logger.debug({
1014
+ issueKey: issue.issueKey,
1015
+ prNumber: issue.prNumber,
1016
+ error: error instanceof Error ? error.message : String(error),
1017
+ }, "Failed to verify PR head advancement after repair");
1018
+ return undefined;
1019
+ }
1020
+ }
911
1021
  async emitLinearActivity(issue, content, options) {
912
1022
  if (!issue.agentSessionId)
913
1023
  return;
@@ -1025,23 +1135,59 @@ function resolvePostRunState(issue) {
1025
1135
  }
1026
1136
  return undefined;
1027
1137
  }
1138
+ function resolveRecoverablePostRunState(issue) {
1139
+ if (!issue.prNumber) {
1140
+ return resolvePostRunState(issue);
1141
+ }
1142
+ if (issue.prState === "merged")
1143
+ return "done";
1144
+ if (issue.prState === "open") {
1145
+ if (issue.lastGitHubFailureSource === "queue_eviction")
1146
+ return "repairing_queue";
1147
+ if (issue.prCheckStatus === "failed" || issue.lastGitHubFailureSource === "branch_ci")
1148
+ return "repairing_ci";
1149
+ if (issue.prReviewState === "changes_requested")
1150
+ return "changes_requested";
1151
+ if (issue.prReviewState === "approved")
1152
+ return "awaiting_queue";
1153
+ return "pr_open";
1154
+ }
1155
+ return resolvePostRunState(issue);
1156
+ }
1028
1157
  function buildFailureContext(issue) {
1158
+ const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
1029
1159
  const queueRepairContext = issue.lastQueueIncidentJson
1030
1160
  ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
1031
1161
  : undefined;
1032
1162
  if (!queueRepairContext
1033
1163
  && !issue.lastGitHubFailureSource
1164
+ && !issue.lastGitHubFailureHeadSha
1165
+ && !issue.lastGitHubFailureSignature
1034
1166
  && !issue.lastGitHubFailureCheckName
1035
- && !issue.lastGitHubFailureCheckUrl) {
1167
+ && !issue.lastGitHubFailureCheckUrl
1168
+ && !storedFailureContext) {
1036
1169
  return undefined;
1037
1170
  }
1038
1171
  return {
1039
1172
  ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
1173
+ ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
1174
+ ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
1040
1175
  ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
1041
1176
  ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
1177
+ ...(storedFailureContext ? storedFailureContext : {}),
1042
1178
  ...(queueRepairContext ? queueRepairContext : {}),
1043
1179
  };
1044
1180
  }
1181
+ function isDuplicateRepairAttempt(issue, context) {
1182
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
1183
+ const headSha = typeof context?.failureHeadSha === "string"
1184
+ ? context.failureHeadSha
1185
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
1186
+ if (!signature)
1187
+ return false;
1188
+ return issue.lastAttemptedFailureSignature === signature
1189
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
1190
+ }
1045
1191
  function appendQueueRepairContext(lines, context) {
1046
1192
  const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
1047
1193
  const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
package/dist/service.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
2
+ import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
2
3
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
3
4
  import { IssueQueryService } from "./issue-query-service.js";
4
5
  import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
@@ -216,6 +217,10 @@ export class PatchRelayService {
216
217
  i.current_linear_state, i.factory_state, i.updated_at,
217
218
  i.pending_run_type,
218
219
  i.pr_number, i.pr_review_state, i.pr_check_status,
220
+ i.last_github_failure_source,
221
+ i.last_github_failure_head_sha,
222
+ i.last_github_failure_check_name,
223
+ i.last_github_failure_context_json,
219
224
  active_run.run_type AS active_run_type,
220
225
  latest_run.run_type AS latest_run_type,
221
226
  latest_run.status AS latest_run_status,
@@ -257,13 +262,22 @@ export class PatchRelayService {
257
262
  ORDER BY i.updated_at DESC, i.issue_key ASC`)
258
263
  .all();
259
264
  return rows.map((row) => {
265
+ const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
260
266
  const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
261
267
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
262
268
  const blockedByCount = Number(row.blocked_by_count ?? 0);
263
269
  const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
270
+ const failureSummary = summarizeGitHubFailureContext(failureContext);
271
+ const derivedStatusNote = blockedByCount > 0
272
+ ? `Blocked by ${blockedByKeys.join(", ")}`
273
+ : failureSummary && (row.factory_state === "repairing_ci"
274
+ || row.factory_state === "repairing_queue"
275
+ || row.factory_state === "failed")
276
+ ? failureSummary
277
+ : statusNote;
264
278
  const statusNoteWithBlockers = blockedByCount > 0
265
279
  ? `Blocked by ${blockedByKeys.join(", ")}`
266
- : statusNote;
280
+ : derivedStatusNote;
267
281
  return {
268
282
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
269
283
  ...(row.title !== null ? { title: String(row.title) } : {}),
@@ -281,6 +295,11 @@ export class PatchRelayService {
281
295
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
282
296
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
283
297
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
298
+ ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
299
+ ...(row.last_github_failure_head_sha !== null ? { latestFailureHeadSha: String(row.last_github_failure_head_sha) } : {}),
300
+ ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
301
+ ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
302
+ ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
284
303
  updatedAt: String(row.updated_at),
285
304
  };
286
305
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {