patchrelay 0.36.2 → 0.36.4

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.36.2",
4
- "commit": "45b497cfbf8e",
5
- "builtAt": "2026-04-08T23:32:38.896Z"
3
+ "version": "0.36.4",
4
+ "commit": "1202c3df2bcb",
5
+ "builtAt": "2026-04-09T07:03:52.765Z"
6
6
  }
@@ -327,6 +327,21 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
327
327
  },
328
328
  };
329
329
  }
330
+ if (gateCheckStatus === "success"
331
+ && reviewDecision === "CHANGES_REQUESTED"
332
+ && mergeConflictDetected
333
+ && issue.factoryState !== "changes_requested"
334
+ && issue.activeRunId === undefined
335
+ && ageMs >= RECONCILIATION_GRACE_MS) {
336
+ return {
337
+ ciEntry,
338
+ finding: {
339
+ status: "fail",
340
+ scope: "github:branch-upkeep",
341
+ message: "PR is still dirty after requested changes, but no branch-upkeep run is active",
342
+ },
343
+ };
344
+ }
330
345
  if (gateCheckStatus === "success"
331
346
  && reviewDecision === "CHANGES_REQUESTED"
332
347
  && latestBlockingReviewHeadSha === pr.headRefOid
@@ -423,6 +438,9 @@ function deriveCiOwner(params) {
423
438
  : "downstream";
424
439
  }
425
440
  if (params.reviewDecision === "CHANGES_REQUESTED") {
441
+ if (params.mergeConflictDetected) {
442
+ return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
443
+ }
426
444
  if (params.factoryState === "changes_requested")
427
445
  return "patchrelay";
428
446
  if (params.reviewQuillAttempt)
@@ -451,6 +469,9 @@ function describeCiOwnership(params) {
451
469
  && params.latestBlockingReviewHeadSha
452
470
  && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
453
471
  if (params.owner === "patchrelay") {
472
+ if (params.mergeConflictDetected) {
473
+ return "PatchRelay owns the next branch-upkeep move";
474
+ }
454
475
  return params.gateCheckStatus === "failure"
455
476
  ? "PatchRelay owns the next CI repair move"
456
477
  : "PatchRelay owns the next requested-changes move";
@@ -479,6 +500,11 @@ function describeCiOwnership(params) {
479
500
  : "Waiting on external GitHub automation";
480
501
  }
481
502
  if (params.reviewDecision === "CHANGES_REQUESTED") {
503
+ if (params.mergeConflictDetected) {
504
+ return headAdvancedPastBlockingReview
505
+ ? "PR is still dirty after a newer pushed head and no branch-upkeep run is active"
506
+ : "PR is still dirty on the current blocked head and no branch-upkeep run is active";
507
+ }
482
508
  return blockingReviewTargetsCurrentHead
483
509
  ? "Requested changes still block the same head and no fix run is active"
484
510
  : "Waiting on review after a newer pushed head";
package/dist/db.js CHANGED
@@ -357,6 +357,7 @@ export class PatchRelayDatabase {
357
357
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
358
358
  last_queue_signal_at, last_queue_incident_json,
359
359
  last_attempted_failure_head_sha, last_attempted_failure_signature,
360
+ ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
360
361
  updated_at
361
362
  ) VALUES (
362
363
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
@@ -369,6 +370,7 @@ export class PatchRelayDatabase {
369
370
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
370
371
  @lastQueueSignalAt, @lastQueueIncidentJson,
371
372
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
373
+ @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
372
374
  @now
373
375
  )
374
376
  `).run({
@@ -415,6 +417,11 @@ export class PatchRelayDatabase {
415
417
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
416
418
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
417
419
  lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
420
+ ciRepairAttempts: params.ciRepairAttempts ?? 0,
421
+ queueRepairAttempts: params.queueRepairAttempts ?? 0,
422
+ reviewFixAttempts: params.reviewFixAttempts ?? 0,
423
+ zombieRecoveryAttempts: params.zombieRecoveryAttempts ?? 0,
424
+ lastZombieRecoveryAt: params.lastZombieRecoveryAt ?? null,
418
425
  now,
419
426
  });
420
427
  }
@@ -16,6 +16,22 @@ function isReviewDecisionChangesRequested(value) {
16
16
  function isReviewDecisionReviewRequired(value) {
17
17
  return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
18
18
  }
19
+ function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
20
+ const promptContext = [
21
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
22
+ `This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
23
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
24
+ ].join(" ");
25
+ return {
26
+ branchUpkeepRequired: true,
27
+ reviewFixMode: "branch_upkeep",
28
+ wakeReason: "branch_upkeep",
29
+ promptContext,
30
+ ...(mergeStateStatus ? { mergeStateStatus } : {}),
31
+ ...(headSha ? { failingHeadSha: headSha } : {}),
32
+ baseBranch,
33
+ };
34
+ }
19
35
  function hasCompletedReviewQuillVerdict(entries) {
20
36
  return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
21
37
  && entry.name === "review-quill/verdict"
@@ -475,6 +491,42 @@ export class IdleIssueReconciler {
475
491
  mergeConflictDetected,
476
492
  downstreamOwned,
477
493
  });
494
+ if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
495
+ && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
496
+ const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
497
+ ? buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
498
+ : undefined;
499
+ this.logger.info({
500
+ issueKey: issue.issueKey,
501
+ prNumber: issue.prNumber,
502
+ from: issue.factoryState,
503
+ runType: reactiveIntent.runType,
504
+ mergeStateStatus: pr.mergeStateStatus,
505
+ }, "Reconciliation: recovered terminal requested-changes issue from GitHub truth");
506
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
507
+ pendingRunType: reactiveIntent.runType,
508
+ ...(pendingRunContext ? { pendingRunContext } : {}),
509
+ clearFailureProvenance: true,
510
+ });
511
+ return;
512
+ }
513
+ if (reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
514
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
515
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
516
+ pendingRunType: reactiveIntent.runType,
517
+ pendingRunContext: buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid),
518
+ });
519
+ this.feed?.publish({
520
+ level: "warn",
521
+ kind: "github",
522
+ issueKey: issue.issueKey,
523
+ projectId: issue.projectId,
524
+ stage: reactiveIntent.compatibilityFactoryState,
525
+ status: "branch_upkeep_queued",
526
+ summary: `PR #${issue.prNumber} is still dirty after requested changes, dispatching branch upkeep`,
527
+ });
528
+ return;
529
+ }
478
530
  if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
479
531
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
480
532
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
@@ -510,7 +562,7 @@ export class IdleIssueReconciler {
510
562
  return;
511
563
  }
512
564
  if (mergeConflictDetected) {
513
- this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR is dirty but not yet queue-admitted; leaving PatchRelay in review state");
565
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR is dirty but no automation owner was derived");
514
566
  }
515
567
  }
516
568
  catch (error) {
@@ -59,6 +59,13 @@ export function deriveIssueSessionReactiveIntent(params) {
59
59
  };
60
60
  }
61
61
  if (params.prReviewState === "changes_requested") {
62
+ if (params.mergeConflictDetected) {
63
+ return {
64
+ runType: "branch_upkeep",
65
+ wakeReason: "branch_upkeep",
66
+ compatibilityFactoryState: "changes_requested",
67
+ };
68
+ }
62
69
  return {
63
70
  runType: "review_fix",
64
71
  wakeReason: "review_changes_requested",
@@ -72,12 +79,15 @@ export function isIssueSessionReadyForExecution(params) {
72
79
  return false;
73
80
  if (params.blockedByCount > 0)
74
81
  return false;
75
- if (params.sessionState === "done" || params.sessionState === "failed" || params.sessionState === "waiting_input") {
82
+ if (params.sessionState === "done" || params.sessionState === "waiting_input") {
76
83
  return false;
77
84
  }
78
85
  if (params.hasPendingWake) {
79
86
  return true;
80
87
  }
88
+ if (params.sessionState === "failed") {
89
+ return false;
90
+ }
81
91
  if (!params.hasLegacyPendingRun) {
82
92
  return false;
83
93
  }
@@ -12,7 +12,7 @@ import { getThreadTurns } from "./codex-thread-utils.js";
12
12
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
13
13
  const DEFAULT_CI_REPAIR_BUDGET = 3;
14
14
  const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
15
- const DEFAULT_REVIEW_FIX_BUDGET = 3;
15
+ const DEFAULT_REVIEW_FIX_BUDGET = 6;
16
16
  const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
17
17
  const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
18
18
  const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
@@ -636,8 +636,8 @@ export class RunOrchestrator {
636
636
  this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
637
637
  return;
638
638
  }
639
- if (runType === "review_fix" && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
640
- this.escalate(issue, runType, `Review fix budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
639
+ if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
640
+ this.escalate(issue, runType, `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
641
641
  return;
642
642
  }
643
643
  // Increment repair counters
@@ -655,7 +655,7 @@ export class RunOrchestrator {
655
655
  return;
656
656
  }
657
657
  }
658
- if (runType === "review_fix") {
658
+ if (isRequestedChangesRunType(runType)) {
659
659
  const updated = this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts + 1 });
660
660
  if (!updated) {
661
661
  this.releaseIssueSessionLease(item.projectId, item.issueId);
@@ -1467,13 +1467,6 @@ export class RunOrchestrator {
1467
1467
  queueRepairAttempts: issue.queueRepairAttempts - 1,
1468
1468
  });
1469
1469
  }
1470
- else if (run.runType === "review_fix" && issue.reviewFixAttempts > 0) {
1471
- this.db.upsertIssueWithLease(lease, {
1472
- projectId: issue.projectId,
1473
- linearIssueId: issue.linearIssueId,
1474
- reviewFixAttempts: issue.reviewFixAttempts - 1,
1475
- });
1476
- }
1477
1470
  if (run.runType === "ci_repair" || run.runType === "queue_repair") {
1478
1471
  this.db.upsertIssueWithLease(lease, {
1479
1472
  projectId: issue.projectId,
@@ -1490,21 +1483,56 @@ export class RunOrchestrator {
1490
1483
  return;
1491
1484
  }
1492
1485
  if (isRequestedChangesRunType(run.runType)) {
1486
+ const refreshedIssue = await this.refreshIssueAfterReactivePublish(run, this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
1487
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
1488
+ const retryContext = project
1489
+ ? await this.resolveRequestedChangesWakeContext(refreshedIssue, run.runType, run.runType === "branch_upkeep"
1490
+ ? {
1491
+ branchUpkeepRequired: true,
1492
+ reviewFixMode: "branch_upkeep",
1493
+ wakeReason: "branch_upkeep",
1494
+ }
1495
+ : undefined, project)
1496
+ : undefined;
1497
+ const retryRunType = resolveRequestedChangesMode(run.runType, retryContext) === "branch_upkeep"
1498
+ ? "branch_upkeep"
1499
+ : "review_fix";
1500
+ const recoveredState = resolveRecoverablePostRunState(refreshedIssue) ?? "failed";
1493
1501
  const interruptedMessage = "Requested-changes run was interrupted before PatchRelay could verify that a new PR head was published";
1494
- this.failRunAndClear(run, interruptedMessage, "escalated");
1502
+ this.failRunAndClear(run, interruptedMessage, recoveredState);
1495
1503
  await this.restoreIdleWorktree(issue);
1496
- const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
1497
- this.feed?.publish({
1498
- level: "error",
1499
- kind: "workflow",
1500
- issueKey: issue.issueKey,
1501
- projectId: run.projectId,
1502
- stage: "review_fix",
1503
- status: "escalated",
1504
- summary: interruptedMessage,
1505
- });
1506
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, interruptedMessage));
1507
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1504
+ const recoveredIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
1505
+ if (recoveredState === "changes_requested") {
1506
+ this.db.upsertIssue({
1507
+ projectId: run.projectId,
1508
+ linearIssueId: run.linearIssueId,
1509
+ pendingRunType: retryRunType,
1510
+ pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
1511
+ });
1512
+ this.feed?.publish({
1513
+ level: "warn",
1514
+ kind: "workflow",
1515
+ issueKey: issue.issueKey,
1516
+ projectId: run.projectId,
1517
+ stage: run.runType,
1518
+ status: "retry_queued",
1519
+ summary: "Requested-changes run was interrupted; PatchRelay will retry from fresh GitHub truth",
1520
+ });
1521
+ this.enqueueIssue(run.projectId, run.linearIssueId);
1522
+ }
1523
+ else {
1524
+ this.feed?.publish({
1525
+ level: "error",
1526
+ kind: "workflow",
1527
+ issueKey: issue.issueKey,
1528
+ projectId: run.projectId,
1529
+ stage: run.runType,
1530
+ status: "escalated",
1531
+ summary: interruptedMessage,
1532
+ });
1533
+ }
1534
+ void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, interruptedMessage));
1535
+ void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
1508
1536
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1509
1537
  return;
1510
1538
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.2",
3
+ "version": "0.36.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {