patchrelay 0.30.0 → 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.0",
4
- "commit": "88eca79d5f8d",
5
- "builtAt": "2026-04-01T02:27:09.365Z"
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;
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS issues (
7
7
  title TEXT,
8
8
  url TEXT,
9
9
  current_linear_state TEXT,
10
+ current_linear_state_type TEXT,
10
11
  factory_state TEXT NOT NULL DEFAULT 'delegated',
11
12
  pending_run_type TEXT,
12
13
  pending_run_context_json TEXT,
@@ -152,6 +153,7 @@ CREATE TABLE IF NOT EXISTS issue_dependencies (
152
153
  blocker_issue_key TEXT,
153
154
  blocker_title TEXT,
154
155
  blocker_current_linear_state TEXT,
156
+ blocker_current_linear_state_type TEXT,
155
157
  updated_at TEXT NOT NULL,
156
158
  PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
157
159
  );
@@ -188,17 +190,24 @@ export function runPatchRelayMigrations(connection) {
188
190
  addColumnIfMissing(connection, "issues", "description", "TEXT");
189
191
  addColumnIfMissing(connection, "issues", "priority", "INTEGER");
190
192
  addColumnIfMissing(connection, "issues", "estimate", "REAL");
193
+ addColumnIfMissing(connection, "issues", "current_linear_state_type", "TEXT");
194
+ addColumnIfMissing(connection, "issue_dependencies", "blocker_current_linear_state_type", "TEXT");
191
195
  // Zombie/stale recovery backoff
192
196
  addColumnIfMissing(connection, "issues", "zombie_recovery_attempts", "INTEGER NOT NULL DEFAULT 0");
193
197
  addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
194
198
  // Preserve GitHub failure provenance so reconciliation can distinguish
195
199
  // branch CI failures from merge-queue evictions after webhook delivery.
196
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");
197
203
  addColumnIfMissing(connection, "issues", "last_github_failure_check_name", "TEXT");
198
204
  addColumnIfMissing(connection, "issues", "last_github_failure_check_url", "TEXT");
205
+ addColumnIfMissing(connection, "issues", "last_github_failure_context_json", "TEXT");
199
206
  addColumnIfMissing(connection, "issues", "last_github_failure_at", "TEXT");
200
207
  addColumnIfMissing(connection, "issues", "last_queue_signal_at", "TEXT");
201
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");
202
211
  }
203
212
  function addColumnIfMissing(connection, table, column, definition) {
204
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";
@@ -104,6 +105,10 @@ export class PatchRelayDatabase {
104
105
  sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
105
106
  values.currentLinearState = params.currentLinearState;
106
107
  }
108
+ if (params.currentLinearStateType !== undefined) {
109
+ sets.push("current_linear_state_type = COALESCE(@currentLinearStateType, current_linear_state_type)");
110
+ values.currentLinearStateType = params.currentLinearStateType;
111
+ }
107
112
  if (params.factoryState !== undefined) {
108
113
  sets.push("factory_state = @factoryState");
109
114
  values.factoryState = params.factoryState;
@@ -160,6 +165,14 @@ export class PatchRelayDatabase {
160
165
  sets.push("last_github_failure_source = @lastGitHubFailureSource");
161
166
  values.lastGitHubFailureSource = params.lastGitHubFailureSource;
162
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
+ }
163
176
  if (params.lastGitHubFailureCheckName !== undefined) {
164
177
  sets.push("last_github_failure_check_name = @lastGitHubFailureCheckName");
165
178
  values.lastGitHubFailureCheckName = params.lastGitHubFailureCheckName;
@@ -168,6 +181,10 @@ export class PatchRelayDatabase {
168
181
  sets.push("last_github_failure_check_url = @lastGitHubFailureCheckUrl");
169
182
  values.lastGitHubFailureCheckUrl = params.lastGitHubFailureCheckUrl;
170
183
  }
184
+ if (params.lastGitHubFailureContextJson !== undefined) {
185
+ sets.push("last_github_failure_context_json = @lastGitHubFailureContextJson");
186
+ values.lastGitHubFailureContextJson = params.lastGitHubFailureContextJson;
187
+ }
171
188
  if (params.lastGitHubFailureAt !== undefined) {
172
189
  sets.push("last_github_failure_at = @lastGitHubFailureAt");
173
190
  values.lastGitHubFailureAt = params.lastGitHubFailureAt;
@@ -180,6 +197,14 @@ export class PatchRelayDatabase {
180
197
  sets.push("last_queue_incident_json = @lastQueueIncidentJson");
181
198
  values.lastQueueIncidentJson = params.lastQueueIncidentJson;
182
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
+ }
183
208
  if (params.ciRepairAttempts !== undefined) {
184
209
  sets.push("ci_repair_attempts = @ciRepairAttempts");
185
210
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -207,20 +232,22 @@ export class PatchRelayDatabase {
207
232
  INSERT INTO issues (
208
233
  project_id, linear_issue_id, issue_key, title, description, url,
209
234
  priority, estimate,
210
- current_linear_state, factory_state, pending_run_type, pending_run_context_json,
235
+ current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
211
236
  branch_name, worktree_path, thread_id, active_run_id,
212
237
  agent_session_id,
213
238
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
214
- 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,
215
241
  updated_at
216
242
  ) VALUES (
217
243
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
218
244
  @priority, @estimate,
219
- @currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
245
+ @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
220
246
  @branchName, @worktreePath, @threadId, @activeRunId,
221
247
  @agentSessionId,
222
248
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
223
- @lastGitHubFailureSource, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
249
+ @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt, @lastQueueSignalAt, @lastQueueIncidentJson,
250
+ @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
224
251
  @now
225
252
  )
226
253
  `).run({
@@ -233,6 +260,7 @@ export class PatchRelayDatabase {
233
260
  priority: params.priority ?? null,
234
261
  estimate: params.estimate ?? null,
235
262
  currentLinearState: params.currentLinearState ?? null,
263
+ currentLinearStateType: params.currentLinearStateType ?? null,
236
264
  factoryState: params.factoryState ?? "delegated",
237
265
  pendingRunType: params.pendingRunType ?? null,
238
266
  pendingRunContextJson: params.pendingRunContextJson ?? null,
@@ -247,11 +275,16 @@ export class PatchRelayDatabase {
247
275
  prReviewState: params.prReviewState ?? null,
248
276
  prCheckStatus: params.prCheckStatus ?? null,
249
277
  lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
278
+ lastGitHubFailureHeadSha: params.lastGitHubFailureHeadSha ?? null,
279
+ lastGitHubFailureSignature: params.lastGitHubFailureSignature ?? null,
250
280
  lastGitHubFailureCheckName: params.lastGitHubFailureCheckName ?? null,
251
281
  lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
282
+ lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
252
283
  lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
253
284
  lastQueueSignalAt: params.lastQueueSignalAt ?? null,
254
285
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
286
+ lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
287
+ lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
255
288
  now,
256
289
  });
257
290
  }
@@ -295,11 +328,12 @@ export class PatchRelayDatabase {
295
328
  blocker_issue_key,
296
329
  blocker_title,
297
330
  blocker_current_linear_state,
331
+ blocker_current_linear_state_type,
298
332
  updated_at
299
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
333
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
300
334
  `);
301
335
  for (const blocker of params.blockers) {
302
- insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, now);
336
+ insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, blocker.blockerCurrentLinearStateType ?? null, now);
303
337
  }
304
338
  }
305
339
  listIssueDependencies(projectId, linearIssueId) {
@@ -311,6 +345,7 @@ export class PatchRelayDatabase {
311
345
  COALESCE(blockers.issue_key, d.blocker_issue_key) AS blocker_issue_key,
312
346
  COALESCE(blockers.title, d.blocker_title) AS blocker_title,
313
347
  COALESCE(blockers.current_linear_state, d.blocker_current_linear_state) AS blocker_current_linear_state,
348
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type) AS blocker_current_linear_state_type,
314
349
  d.updated_at
315
350
  FROM issue_dependencies d
316
351
  LEFT JOIN issues blockers
@@ -328,6 +363,9 @@ export class PatchRelayDatabase {
328
363
  ...(row.blocker_current_linear_state !== null && row.blocker_current_linear_state !== undefined
329
364
  ? { blockerCurrentLinearState: String(row.blocker_current_linear_state) }
330
365
  : {}),
366
+ ...(row.blocker_current_linear_state_type !== null && row.blocker_current_linear_state_type !== undefined
367
+ ? { blockerCurrentLinearStateType: String(row.blocker_current_linear_state_type) }
368
+ : {}),
331
369
  updatedAt: String(row.updated_at),
332
370
  }));
333
371
  }
@@ -351,7 +389,10 @@ export class PatchRelayDatabase {
351
389
  ON blockers.project_id = d.project_id
352
390
  AND blockers.linear_issue_id = d.blocker_linear_issue_id
353
391
  WHERE d.project_id = ? AND d.linear_issue_id = ?
354
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
392
+ AND (
393
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
394
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
395
+ )
355
396
  `).get(projectId, linearIssueId);
356
397
  return Number(row?.count ?? 0);
357
398
  }
@@ -370,7 +411,10 @@ export class PatchRelayDatabase {
370
411
  AND blockers.linear_issue_id = d.blocker_linear_issue_id
371
412
  WHERE d.project_id = i.project_id
372
413
  AND d.linear_issue_id = i.linear_issue_id
373
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
414
+ AND (
415
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
416
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
417
+ )
374
418
  )
375
419
  `)
376
420
  .all();
@@ -491,6 +535,8 @@ export class PatchRelayDatabase {
491
535
  // ─── View builders ──────────────────────────────────────────────
492
536
  issueToTrackedIssue(issue) {
493
537
  const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
538
+ const unresolvedBlockedBy = blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
539
+ const failureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
494
540
  return {
495
541
  id: issue.id,
496
542
  projectId: issue.projectId,
@@ -500,11 +546,15 @@ export class PatchRelayDatabase {
500
546
  ...(issue.url ? { issueUrl: issue.url } : {}),
501
547
  ...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
502
548
  factoryState: issue.factoryState,
503
- blockedByCount: blockedBy.filter((entry) => !isDoneState(entry.blockerCurrentLinearState)).length,
504
- blockedByKeys: blockedBy
505
- .filter((entry) => !isDoneState(entry.blockerCurrentLinearState))
549
+ blockedByCount: unresolvedBlockedBy.length,
550
+ blockedByKeys: unresolvedBlockedBy
506
551
  .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
507
- readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined,
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 } : {}),
508
558
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
509
559
  ...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
510
560
  updatedAt: issue.updatedAt,
@@ -544,6 +594,9 @@ function mapIssueRow(row) {
544
594
  ...(row.priority !== null && row.priority !== undefined ? { priority: Number(row.priority) } : {}),
545
595
  ...(row.estimate !== null && row.estimate !== undefined ? { estimate: Number(row.estimate) } : {}),
546
596
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
597
+ ...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
598
+ ? { currentLinearStateType: String(row.current_linear_state_type) }
599
+ : {}),
547
600
  factoryState: String(row.factory_state ?? "delegated"),
548
601
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
549
602
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
@@ -561,12 +614,21 @@ function mapIssueRow(row) {
561
614
  ...(row.last_github_failure_source !== null && row.last_github_failure_source !== undefined
562
615
  ? { lastGitHubFailureSource: String(row.last_github_failure_source) }
563
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
+ : {}),
564
623
  ...(row.last_github_failure_check_name !== null && row.last_github_failure_check_name !== undefined
565
624
  ? { lastGitHubFailureCheckName: String(row.last_github_failure_check_name) }
566
625
  : {}),
567
626
  ...(row.last_github_failure_check_url !== null && row.last_github_failure_check_url !== undefined
568
627
  ? { lastGitHubFailureCheckUrl: String(row.last_github_failure_check_url) }
569
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
+ : {}),
570
632
  ...(row.last_github_failure_at !== null && row.last_github_failure_at !== undefined
571
633
  ? { lastGitHubFailureAt: String(row.last_github_failure_at) }
572
634
  : {}),
@@ -576,6 +638,12 @@ function mapIssueRow(row) {
576
638
  ...(row.last_queue_incident_json !== null && row.last_queue_incident_json !== undefined
577
639
  ? { lastQueueIncidentJson: String(row.last_queue_incident_json) }
578
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
+ : {}),
579
647
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
580
648
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
581
649
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -602,6 +670,6 @@ function mapRunRow(row) {
602
670
  ...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
603
671
  };
604
672
  }
605
- function isDoneState(stateName) {
606
- return stateName?.trim().toLowerCase() === "done";
673
+ function isResolvedLinearState(stateType, stateName) {
674
+ return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
607
675
  }
@@ -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
+ }