patchrelay 0.74.3 → 0.74.5

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.74.3",
4
- "commit": "21e6267a7b1c",
5
- "builtAt": "2026-05-28T21:47:39.635Z"
3
+ "version": "0.74.5",
4
+ "commit": "c495abc1770b",
5
+ "builtAt": "2026-05-29T08:13:54.437Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -402,42 +402,7 @@ export class CliDataAccess extends CliOperatorApiClient {
402
402
  });
403
403
  }
404
404
  list(options) {
405
- const conditions = [];
406
- const values = [];
407
- if (options?.project) {
408
- conditions.push("i.project_id = ?");
409
- values.push(options.project);
410
- }
411
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
412
- const rows = this.db.connection
413
- .prepare(`
414
- SELECT
415
- i.project_id,
416
- i.linear_issue_id,
417
- i.issue_key,
418
- i.title,
419
- i.current_linear_state,
420
- i.factory_state,
421
- i.updated_at,
422
- s.session_state,
423
- s.waiting_reason,
424
- active_run.run_type AS active_run_type,
425
- latest_run.run_type AS latest_run_type,
426
- latest_run.status AS latest_run_status
427
- FROM issues i
428
- LEFT JOIN issue_sessions s
429
- ON s.project_id = i.project_id
430
- AND s.linear_issue_id = i.linear_issue_id
431
- LEFT JOIN runs active_run ON active_run.id = i.active_run_id
432
- LEFT JOIN runs latest_run ON latest_run.id = (
433
- SELECT r.id FROM runs r
434
- WHERE r.project_id = i.project_id AND r.linear_issue_id = i.linear_issue_id
435
- ORDER BY r.id DESC LIMIT 1
436
- )
437
- ${whereClause}
438
- ORDER BY i.updated_at DESC, i.issue_key ASC
439
- `)
440
- .all(...values);
405
+ const rows = this.db.issues.listIssueSummaryRows(options?.project);
441
406
  const items = rows.map((row) => {
442
407
  const detachedActiveRun = row.active_run_type === null
443
408
  && (row.latest_run_status === "queued" || row.latest_run_status === "running");
@@ -292,4 +292,82 @@ export class IssueSessionStore {
292
292
  const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
293
293
  this.releaseIssueSessionLease(projectId, linearIssueId, lease?.leaseId);
294
294
  }
295
+ /**
296
+ * Raw rows for the tracked-issue read model: one row per issue session joined
297
+ * to its issue, active/latest run, pending session-event count, and blocker
298
+ * rollup. Row shaping into the read model lives in the query layer; this owns
299
+ * only the SQL so schema knowledge stays in the persistence layer.
300
+ */
301
+ listTrackedIssueRows() {
302
+ return this.connection
303
+ .prepare(`SELECT
304
+ s.project_id, s.linear_issue_id, s.issue_key, i.title,
305
+ i.current_linear_state, i.factory_state, i.delegated_to_patchrelay, s.session_state, s.waiting_reason, s.summary_text, s.display_updated_at,
306
+ i.pending_run_type,
307
+ i.orchestration_settle_until,
308
+ i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
309
+ i.last_github_ci_snapshot_json,
310
+ i.last_github_failure_source,
311
+ i.last_github_failure_head_sha,
312
+ i.last_github_failure_check_name,
313
+ i.last_github_failure_context_json,
314
+ active_run.run_type AS active_run_type,
315
+ active_run.completion_check_thread_id AS active_completion_check_thread_id,
316
+ active_run.completion_check_outcome AS active_completion_check_outcome,
317
+ latest_run.run_type AS latest_run_type,
318
+ latest_run.status AS latest_run_status,
319
+ latest_run.summary_json AS latest_run_summary_json,
320
+ latest_run.report_json AS latest_run_report_json,
321
+ latest_run.completion_check_thread_id AS latest_run_completion_check_thread_id,
322
+ latest_run.completion_check_outcome AS latest_run_completion_check_outcome,
323
+ latest_run.completion_check_summary AS latest_run_completion_check_summary,
324
+ latest_run.completion_check_question AS latest_run_completion_check_question,
325
+ latest_run.completion_check_why AS latest_run_completion_check_why,
326
+ latest_run.completion_check_recommended_reply AS latest_run_completion_check_recommended_reply,
327
+ (
328
+ SELECT COUNT(*)
329
+ FROM issue_session_events e
330
+ WHERE e.project_id = s.project_id
331
+ AND e.linear_issue_id = s.linear_issue_id
332
+ AND e.processed_at IS NULL
333
+ ) AS pending_session_event_count,
334
+ (
335
+ SELECT COUNT(*)
336
+ FROM issue_dependencies d
337
+ LEFT JOIN issues blockers
338
+ ON blockers.project_id = d.project_id
339
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
340
+ WHERE d.project_id = s.project_id
341
+ AND d.linear_issue_id = s.linear_issue_id
342
+ AND (
343
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
344
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
345
+ )
346
+ ) AS blocked_by_count,
347
+ (
348
+ SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
349
+ FROM issue_dependencies d
350
+ LEFT JOIN issues blockers
351
+ ON blockers.project_id = d.project_id
352
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
353
+ WHERE d.project_id = s.project_id
354
+ AND d.linear_issue_id = s.linear_issue_id
355
+ AND (
356
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
357
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
358
+ )
359
+ ) AS blocked_by_keys_json
360
+ FROM issue_sessions s
361
+ LEFT JOIN issues i
362
+ ON i.project_id = s.project_id
363
+ AND i.linear_issue_id = s.linear_issue_id
364
+ LEFT JOIN runs active_run ON active_run.id = COALESCE(s.active_run_id, i.active_run_id)
365
+ LEFT JOIN runs latest_run ON latest_run.id = (
366
+ SELECT r.id FROM runs r
367
+ WHERE r.project_id = s.project_id AND r.linear_issue_id = s.linear_issue_id
368
+ ORDER BY r.id DESC LIMIT 1
369
+ )
370
+ ORDER BY s.display_updated_at DESC, s.issue_key ASC`)
371
+ .all();
372
+ }
295
373
  }
@@ -354,6 +354,44 @@ export class IssueStore {
354
354
  return undefined;
355
355
  }
356
356
  }
357
+ /**
358
+ * Raw rows for the CLI issue-summary read model (one row per issue joined to
359
+ * its session and active/latest run), optionally scoped to a project. Row
360
+ * shaping lives in the CLI layer; this owns only the SQL.
361
+ */
362
+ listIssueSummaryRows(project) {
363
+ const whereClause = project ? "WHERE i.project_id = ?" : "";
364
+ const values = project ? [project] : [];
365
+ return this.connection
366
+ .prepare(`
367
+ SELECT
368
+ i.project_id,
369
+ i.linear_issue_id,
370
+ i.issue_key,
371
+ i.title,
372
+ i.current_linear_state,
373
+ i.factory_state,
374
+ i.updated_at,
375
+ s.session_state,
376
+ s.waiting_reason,
377
+ active_run.run_type AS active_run_type,
378
+ latest_run.run_type AS latest_run_type,
379
+ latest_run.status AS latest_run_status
380
+ FROM issues i
381
+ LEFT JOIN issue_sessions s
382
+ ON s.project_id = i.project_id
383
+ AND s.linear_issue_id = i.linear_issue_id
384
+ LEFT JOIN runs active_run ON active_run.id = i.active_run_id
385
+ LEFT JOIN runs latest_run ON latest_run.id = (
386
+ SELECT r.id FROM runs r
387
+ WHERE r.project_id = i.project_id AND r.linear_issue_id = i.linear_issue_id
388
+ ORDER BY r.id DESC LIMIT 1
389
+ )
390
+ ${whereClause}
391
+ ORDER BY i.updated_at DESC, i.issue_key ASC
392
+ `)
393
+ .all(...values);
394
+ }
357
395
  }
358
396
  export function mapIssueRow(row) {
359
397
  return {
@@ -170,6 +170,10 @@ export class LinearInstallationStore {
170
170
  deleteLinearInstallation(installationId) {
171
171
  this.connection.prepare("DELETE FROM linear_installations WHERE id = ?").run(installationId);
172
172
  }
173
+ deleteCatalogForInstallation(installationId) {
174
+ this.connection.prepare("DELETE FROM linear_catalog_teams WHERE installation_id = ?").run(installationId);
175
+ this.connection.prepare("DELETE FROM linear_catalog_projects WHERE installation_id = ?").run(installationId);
176
+ }
173
177
  getLinearInstallationForProject(projectId) {
174
178
  const row = this.connection
175
179
  .prepare(`
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Every failure-provenance field on an issue, set to null.
3
+ *
4
+ * Spread into an issue upsert when a run reaches a clean advancing or terminal
5
+ * state (for example `awaiting_queue` or `done`) and any previously recorded
6
+ * GitHub failure or queue-incident context must not leak into the next
7
+ * decision. Keeping the field set in one place stops it from drifting between
8
+ * the run finalizer, the reconcilers, the completion check, and the webhook
9
+ * state projector.
10
+ */
11
+ export const CLEARED_FAILURE_PROVENANCE = {
12
+ lastGitHubFailureSource: null,
13
+ lastGitHubFailureHeadSha: null,
14
+ lastGitHubFailureSignature: null,
15
+ lastGitHubFailureCheckName: null,
16
+ lastGitHubFailureCheckUrl: null,
17
+ lastGitHubFailureContextJson: null,
18
+ lastGitHubFailureAt: null,
19
+ lastQueueIncidentJson: null,
20
+ lastAttemptedFailureHeadSha: null,
21
+ lastAttemptedFailureSignature: null,
22
+ lastAttemptedFailureAt: null,
23
+ };
@@ -1,3 +1,4 @@
1
+ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
1
2
  import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver } from "./github-failure-context.js";
2
3
  import { buildClosedPrCleanupFields } from "./pr-state.js";
3
4
  import { canClearFailureProvenance, deriveImmediatePrCheckStatus, getGateCheckNames, getPrimaryGateCheckName, isGateCheckEvent, isMetadataOnlyCheckEvent, isQueueEvictionFailure, isStaleGateEvent, isSettledBranchFailure, resolveGitHubFactoryStateForEvent, } from "./github-webhook-policy.js";
@@ -313,17 +314,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
313
314
  deps.db.issues.upsertIssue({
314
315
  projectId: issue.projectId,
315
316
  linearIssueId: issue.linearIssueId,
316
- lastGitHubFailureSource: null,
317
- lastGitHubFailureHeadSha: null,
318
- lastGitHubFailureSignature: null,
319
- lastGitHubFailureCheckName: null,
320
- lastGitHubFailureCheckUrl: null,
321
- lastGitHubFailureContextJson: null,
322
- lastGitHubFailureAt: null,
323
- lastQueueIncidentJson: null,
324
- lastAttemptedFailureHeadSha: null,
325
- lastAttemptedFailureSignature: null,
326
- lastAttemptedFailureAt: null,
317
+ ...CLEARED_FAILURE_PROVENANCE,
327
318
  });
328
319
  }
329
320
  }
@@ -1,4 +1,5 @@
1
1
  import { TERMINAL_STATES } from "./factory-state.js";
2
+ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
2
3
  import { DEPLOY_WATCH_TIMEOUT_MS, evaluateDeploy, isDeployTrackingEnabled, } from "./post-merge-deploy.js";
3
4
  import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
4
5
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
@@ -9,7 +10,7 @@ import { getReviewFixBudget } from "./run-budgets.js";
9
10
  import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
10
11
  import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
11
12
  import { buildPrStateUpdates } from "./reconcile-pr-state-updates.js";
12
- import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
13
+ import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
13
14
  import { execCommand } from "./utils.js";
14
15
  export class IdleIssueReconciler {
15
16
  db;
@@ -239,19 +240,7 @@ export class IdleIssueReconciler {
239
240
  }
240
241
  : {}),
241
242
  ...(options?.clearFailureProvenance
242
- ? {
243
- lastGitHubFailureSource: null,
244
- lastGitHubFailureHeadSha: null,
245
- lastGitHubFailureSignature: null,
246
- lastGitHubFailureCheckName: null,
247
- lastGitHubFailureCheckUrl: null,
248
- lastGitHubFailureContextJson: null,
249
- lastGitHubFailureAt: null,
250
- lastQueueIncidentJson: null,
251
- lastAttemptedFailureHeadSha: null,
252
- lastAttemptedFailureSignature: null,
253
- lastAttemptedFailureAt: null,
254
- }
243
+ ? { ...CLEARED_FAILURE_PROVENANCE }
255
244
  : {}),
256
245
  });
257
246
  const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -277,18 +266,19 @@ export class IdleIssueReconciler {
277
266
  // is needed here.
278
267
  }
279
268
  recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
280
- let eventType;
269
+ const eventType = reactiveWakeEventType(runType);
281
270
  let dedupeKey;
282
- if (runType === "queue_repair") {
283
- eventType = "merge_steward_incident";
284
- dedupeKey = `${dedupeScope}:queue_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
285
- }
286
- else if (runType === "ci_repair") {
287
- eventType = "settled_red_ci";
288
- dedupeKey = `${dedupeScope}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`;
271
+ if (runType === "queue_repair" || runType === "ci_repair") {
272
+ dedupeKey = buildRepairWakeDedupeKey({
273
+ scope: dedupeScope,
274
+ runType,
275
+ linearIssueId: issue.linearIssueId,
276
+ signature: issue.lastGitHubFailureSignature,
277
+ prHeadSha: issue.prHeadSha,
278
+ failureHeadSha: issue.lastGitHubFailureHeadSha,
279
+ });
289
280
  }
290
281
  else if (runType === "review_fix" || runType === "branch_upkeep") {
291
- eventType = "review_changes_requested";
292
282
  dedupeKey = buildRequestedChangesWakeIdentity({
293
283
  linearIssueId: issue.linearIssueId,
294
284
  runType,
@@ -296,7 +286,6 @@ export class IdleIssueReconciler {
296
286
  }).dedupeKey;
297
287
  }
298
288
  else {
299
- eventType = "delegated";
300
289
  dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
301
290
  }
302
291
  const requestedChangesIdentity = eventType === "review_changes_requested"
@@ -1,9 +1,7 @@
1
1
  import { ACTIVE_RUN_STATES } from "./factory-state.js";
2
2
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
3
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
4
- function isRequestedChangesRunType(runType) {
5
- return runType === "review_fix" || runType === "branch_upkeep";
6
- }
4
+ import { isRequestedChangesRunType } from "./reactive-pr-state.js";
7
5
  function resolveRetryRunType(runType, context) {
8
6
  if (runType === "branch_upkeep") {
9
7
  return "branch_upkeep";
@@ -1,3 +1,4 @@
1
+ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
1
2
  import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
2
3
  import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
3
4
  function shouldContinueForUnpublishedLocalChanges(message) {
@@ -183,17 +184,7 @@ export async function handleNoPrCompletionCheck(params) {
183
184
  pendingRunType: null,
184
185
  pendingRunContextJson: null,
185
186
  orchestrationSettleUntil: null,
186
- lastGitHubFailureSource: null,
187
- lastGitHubFailureHeadSha: null,
188
- lastGitHubFailureSignature: null,
189
- lastGitHubFailureCheckName: null,
190
- lastGitHubFailureCheckUrl: null,
191
- lastGitHubFailureContextJson: null,
192
- lastGitHubFailureAt: null,
193
- lastQueueIncidentJson: null,
194
- lastAttemptedFailureHeadSha: null,
195
- lastAttemptedFailureSignature: null,
196
- lastAttemptedFailureAt: null,
187
+ ...CLEARED_FAILURE_PROVENANCE,
197
188
  });
198
189
  return true;
199
190
  });
@@ -1,4 +1,5 @@
1
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
+ import { buildRepairWakeDedupeKey } from "./reactive-wake-keys.js";
2
3
  import { execCommand } from "./utils.js";
3
4
  const QUEUE_HEALTH_GRACE_MS = 120_000;
4
5
  const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
@@ -167,7 +168,12 @@ export class QueueHealthMonitor {
167
168
  this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
168
169
  eventType: "merge_steward_incident",
169
170
  eventJson: JSON.stringify(pendingRunContext),
170
- dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
171
+ dedupeKey: buildRepairWakeDedupeKey({
172
+ scope: "queue_health",
173
+ runType: "queue_repair",
174
+ linearIssueId: issue.linearIssueId,
175
+ signature,
176
+ }),
171
177
  });
172
178
  this.advancer.advanceIdleIssue(issue, "repairing_queue");
173
179
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
@@ -2,6 +2,15 @@ import { readRemotePrState } from "./remote-pr-state.js";
2
2
  export function isRequestedChangesRunType(runType) {
3
3
  return runType === "review_fix" || runType === "branch_upkeep";
4
4
  }
5
+ /**
6
+ * The terminal state a failed run should land the issue in: requested-changes
7
+ * repairs escalate (a human asked for the change, so a silent failure must
8
+ * surface), everything else fails. Centralized so the mapping cannot diverge
9
+ * between the launcher and the notification handler.
10
+ */
11
+ export function resolveFailureFactoryState(runType) {
12
+ return isRequestedChangesRunType(runType) ? "escalated" : "failed";
13
+ }
5
14
  export function normalizeRemotePrState(value) {
6
15
  const normalized = value?.trim().toUpperCase();
7
16
  if (normalized === "OPEN")
@@ -1,3 +1,4 @@
1
+ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
1
2
  import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
2
3
  import { readReactivePublishDelta } from "./reactive-publish-delta.js";
3
4
  import { readLatestRequestedChangesReviewContext } from "./remote-pr-review.js";
@@ -173,17 +174,7 @@ export class ReactiveRunPolicy {
173
174
  ...((headAdvanced || reviewFixHeadAdvanced)
174
175
  ? {
175
176
  prCheckStatus: "pending",
176
- lastGitHubFailureSource: null,
177
- lastGitHubFailureHeadSha: null,
178
- lastGitHubFailureSignature: null,
179
- lastGitHubFailureCheckName: null,
180
- lastGitHubFailureCheckUrl: null,
181
- lastGitHubFailureContextJson: null,
182
- lastGitHubFailureAt: null,
183
- lastQueueIncidentJson: null,
184
- lastAttemptedFailureHeadSha: null,
185
- lastAttemptedFailureSignature: null,
186
- lastAttemptedFailureAt: null,
177
+ ...CLEARED_FAILURE_PROVENANCE,
187
178
  lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
188
179
  lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
189
180
  lastGitHubCiSnapshotGateCheckStatus: "pending",
@@ -62,3 +62,32 @@ function parseObject(raw) {
62
62
  return undefined;
63
63
  }
64
64
  }
65
+ /**
66
+ * Map a run type to the issue-session event type that wakes it. Shared by every
67
+ * reconciler that records a reactive wake (idle reconciliation, startup
68
+ * recovery, queue health) so the mapping stays in one place.
69
+ */
70
+ export function reactiveWakeEventType(runType) {
71
+ switch (runType) {
72
+ case "queue_repair":
73
+ return "merge_steward_incident";
74
+ case "ci_repair":
75
+ return "settled_red_ci";
76
+ case "review_fix":
77
+ case "branch_upkeep":
78
+ return "review_changes_requested";
79
+ default:
80
+ return "delegated";
81
+ }
82
+ }
83
+ /**
84
+ * Build the dedupe key for a CI/queue repair wake. The discriminator prefers the
85
+ * failure signature, then the PR head, then the recorded failure head, falling
86
+ * back to "unknown" — the same precedence every reconciler used independently
87
+ * before this was consolidated (a prior divergence here swallowed fresh repair
88
+ * incidents after the main branch advanced).
89
+ */
90
+ export function buildRepairWakeDedupeKey(params) {
91
+ const discriminator = params.signature ?? params.prHeadSha ?? params.failureHeadSha ?? "unknown";
92
+ return `${params.scope}:${params.runType}:${params.linearIssueId}:${discriminator}`;
93
+ }
@@ -1,3 +1,4 @@
1
+ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
1
2
  import { buildStageReport, countEventMethods } from "./run-reporting.js";
2
3
  import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
3
4
  import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
@@ -449,19 +450,7 @@ export class RunFinalizer {
449
450
  pendingRunType: null,
450
451
  pendingRunContextJson: null,
451
452
  ...(postRunFollowUp ? {} : (postRunState === "awaiting_queue" || postRunState === "done"
452
- ? {
453
- lastGitHubFailureSource: null,
454
- lastGitHubFailureHeadSha: null,
455
- lastGitHubFailureSignature: null,
456
- lastGitHubFailureCheckName: null,
457
- lastGitHubFailureCheckUrl: null,
458
- lastGitHubFailureContextJson: null,
459
- lastGitHubFailureAt: null,
460
- lastQueueIncidentJson: null,
461
- lastAttemptedFailureHeadSha: null,
462
- lastAttemptedFailureSignature: null,
463
- lastAttemptedFailureAt: null,
464
- }
453
+ ? { ...CLEARED_FAILURE_PROVENANCE }
465
454
  : {})),
466
455
  });
467
456
  if (postRunFollowUp) {
@@ -1,3 +1,4 @@
1
+ import { resolveFailureFactoryState } from "./reactive-pr-state.js";
1
2
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
2
3
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
4
  import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
@@ -228,7 +229,7 @@ export class RunLauncher {
228
229
  const message = error instanceof Error ? error.message : String(error);
229
230
  const lostLease = error instanceof Error && error.name === "IssueSessionLeaseLostError";
230
231
  if (!lostLease) {
231
- const nextState = params.isRequestedChangesRunType(params.runType) ? "escalated" : "failed";
232
+ const nextState = resolveFailureFactoryState(params.runType);
232
233
  this.db.issueSessions.finishRunWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, params.run.id, {
233
234
  status: "failed",
234
235
  failureReason: message,
@@ -1,9 +1,7 @@
1
1
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
2
2
  import { extractTurnId, resolveRunCompletionStatus } from "./run-reporting.js";
3
3
  import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
4
- function isRequestedChangesRunType(runType) {
5
- return runType === "review_fix" || runType === "branch_upkeep";
6
- }
4
+ import { resolveFailureFactoryState } from "./reactive-pr-state.js";
7
5
  const DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
8
6
  export class RunNotificationHandler {
9
7
  config;
@@ -91,7 +89,7 @@ export class RunNotificationHandler {
91
89
  this.activeThreadId = undefined;
92
90
  return;
93
91
  }
94
- const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
92
+ const nextState = resolveFailureFactoryState(run.runType);
95
93
  const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
96
94
  this.db.issueSessions.finishRunWithLease(lease, run.id, {
97
95
  status: "failed",
@@ -1,3 +1,4 @@
1
+ import { isRequestedChangesRunType } from "./reactive-pr-state.js";
1
2
  import { summarizeCurrentThread } from "./run-reporting.js";
2
3
  import { buildReviewRoundStartedActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
3
4
  import { CompletionCheckService } from "./completion-check.js";
@@ -25,9 +26,6 @@ import { CodexThreadMaterializingError, isThreadMaterializingError } from "./cod
25
26
  function lowerCaseFirst(value) {
26
27
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
27
28
  }
28
- function isRequestedChangesRunType(runType) {
29
- return runType === "review_fix" || runType === "branch_upkeep";
30
- }
31
29
  function shouldDelayZombieRecoveryLaunch(issue, issueSession, runType) {
32
30
  if (issue.zombieRecoveryAttempts <= 0)
33
31
  return 0;
@@ -420,7 +418,6 @@ export class RunOrchestrator {
420
418
  assertLaunchLease: (targetRun, phase) => this.assertLaunchLease(targetRun, phase),
421
419
  linearSync: this.linearSync,
422
420
  releaseLease: (projectId, issueId) => this.releaseIssueSessionLease(projectId, issueId),
423
- isRequestedChangesRunType,
424
421
  lowerCaseFirst,
425
422
  });
426
423
  this.assertLaunchLease(run, "before recording the active thread");
@@ -1,7 +1,7 @@
1
1
  import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
2
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
4
- import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
4
+ import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
5
5
  export class ServiceStartupRecovery {
6
6
  db;
7
7
  linearProvider;
@@ -170,20 +170,21 @@ export class ServiceStartupRecovery {
170
170
  }
171
171
  }
172
172
  appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
173
- const eventType = runType === "queue_repair"
174
- ? "merge_steward_incident"
175
- : runType === "ci_repair"
176
- ? "settled_red_ci"
177
- : "review_changes_requested";
178
- const dedupeKey = runType === "queue_repair"
179
- ? `startup_recovery:queue_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
180
- : runType === "ci_repair"
181
- ? `startup_recovery:ci_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
182
- : buildRequestedChangesWakeIdentity({
183
- linearIssueId,
184
- runType,
185
- headSha: issue.prHeadSha,
186
- }).dedupeKey;
173
+ const eventType = reactiveWakeEventType(runType);
174
+ const dedupeKey = runType === "queue_repair" || runType === "ci_repair"
175
+ ? buildRepairWakeDedupeKey({
176
+ scope: "startup_recovery",
177
+ runType,
178
+ linearIssueId,
179
+ signature: issue.lastGitHubFailureSignature,
180
+ prHeadSha: issue.prHeadSha,
181
+ failureHeadSha: issue.lastGitHubFailureHeadSha,
182
+ })
183
+ : buildRequestedChangesWakeIdentity({
184
+ linearIssueId,
185
+ runType,
186
+ headSha: issue.prHeadSha,
187
+ }).dedupeKey;
187
188
  const requestedChangesIdentity = eventType === "review_changes_requested"
188
189
  ? buildRequestedChangesWakeIdentity({
189
190
  linearIssueId,
package/dist/service.js CHANGED
@@ -261,8 +261,7 @@ export class PatchRelayService {
261
261
  }
262
262
  this.db.transaction(() => {
263
263
  this.db.linearInstallations.unlinkInstallationProjects(installation.id);
264
- this.db.connection.prepare("DELETE FROM linear_catalog_teams WHERE installation_id = ?").run(installation.id);
265
- this.db.connection.prepare("DELETE FROM linear_catalog_projects WHERE installation_id = ?").run(installation.id);
264
+ this.db.linearInstallations.deleteCatalogForInstallation(installation.id);
266
265
  this.db.linearInstallations.deleteLinearInstallation(installation.id);
267
266
  });
268
267
  return {
@@ -78,76 +78,7 @@ export class TrackedIssueListQuery {
78
78
  this.db = db;
79
79
  }
80
80
  listTrackedIssues() {
81
- const rows = this.db.connection
82
- .prepare(`SELECT
83
- s.project_id, s.linear_issue_id, s.issue_key, i.title,
84
- i.current_linear_state, i.factory_state, i.delegated_to_patchrelay, s.session_state, s.waiting_reason, s.summary_text, s.display_updated_at,
85
- i.pending_run_type,
86
- i.orchestration_settle_until,
87
- i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
88
- i.last_github_ci_snapshot_json,
89
- i.last_github_failure_source,
90
- i.last_github_failure_head_sha,
91
- i.last_github_failure_check_name,
92
- i.last_github_failure_context_json,
93
- active_run.run_type AS active_run_type,
94
- active_run.completion_check_thread_id AS active_completion_check_thread_id,
95
- active_run.completion_check_outcome AS active_completion_check_outcome,
96
- latest_run.run_type AS latest_run_type,
97
- latest_run.status AS latest_run_status,
98
- latest_run.summary_json AS latest_run_summary_json,
99
- latest_run.report_json AS latest_run_report_json,
100
- latest_run.completion_check_thread_id AS latest_run_completion_check_thread_id,
101
- latest_run.completion_check_outcome AS latest_run_completion_check_outcome,
102
- latest_run.completion_check_summary AS latest_run_completion_check_summary,
103
- latest_run.completion_check_question AS latest_run_completion_check_question,
104
- latest_run.completion_check_why AS latest_run_completion_check_why,
105
- latest_run.completion_check_recommended_reply AS latest_run_completion_check_recommended_reply,
106
- (
107
- SELECT COUNT(*)
108
- FROM issue_session_events e
109
- WHERE e.project_id = s.project_id
110
- AND e.linear_issue_id = s.linear_issue_id
111
- AND e.processed_at IS NULL
112
- ) AS pending_session_event_count,
113
- (
114
- SELECT COUNT(*)
115
- FROM issue_dependencies d
116
- LEFT JOIN issues blockers
117
- ON blockers.project_id = d.project_id
118
- AND blockers.linear_issue_id = d.blocker_linear_issue_id
119
- WHERE d.project_id = s.project_id
120
- AND d.linear_issue_id = s.linear_issue_id
121
- AND (
122
- COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
123
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
124
- )
125
- ) AS blocked_by_count,
126
- (
127
- SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
128
- FROM issue_dependencies d
129
- LEFT JOIN issues blockers
130
- ON blockers.project_id = d.project_id
131
- AND blockers.linear_issue_id = d.blocker_linear_issue_id
132
- WHERE d.project_id = s.project_id
133
- AND d.linear_issue_id = s.linear_issue_id
134
- AND (
135
- COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
136
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
137
- )
138
- ) AS blocked_by_keys_json
139
- FROM issue_sessions s
140
- LEFT JOIN issues i
141
- ON i.project_id = s.project_id
142
- AND i.linear_issue_id = s.linear_issue_id
143
- LEFT JOIN runs active_run ON active_run.id = COALESCE(s.active_run_id, i.active_run_id)
144
- LEFT JOIN runs latest_run ON latest_run.id = (
145
- SELECT r.id FROM runs r
146
- WHERE r.project_id = s.project_id AND r.linear_issue_id = s.linear_issue_id
147
- ORDER BY r.id DESC LIMIT 1
148
- )
149
- ORDER BY s.display_updated_at DESC, s.issue_key ASC`)
150
- .all();
81
+ const rows = this.db.issueSessions.listTrackedIssueRows();
151
82
  return rows.map((row) => {
152
83
  const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
153
84
  const prChecksSummary = parseCiSnapshotSummary(typeof row.last_github_ci_snapshot_json === "string" ? row.last_github_ci_snapshot_json : undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.74.3",
3
+ "version": "0.74.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {