patchrelay 0.35.17 → 0.36.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.
@@ -49,6 +49,8 @@ export function buildRunStartedActivity(runType) {
49
49
  switch (runType) {
50
50
  case "review_fix":
51
51
  return { type: "action", action: "Addressing", parameter: "review feedback" };
52
+ case "branch_upkeep":
53
+ return { type: "action", action: "Repairing", parameter: "PR branch upkeep after requested changes" };
52
54
  case "ci_repair":
53
55
  return { type: "action", action: "Repairing", parameter: "failing CI checks" };
54
56
  case "queue_repair":
@@ -241,8 +241,10 @@ function renderStatusComment(db, issue, trackedIssue, options) {
241
241
  factoryState: issue.factoryState,
242
242
  pendingRunType: issue.pendingRunType,
243
243
  ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
244
+ prHeadSha: issue.prHeadSha,
244
245
  prReviewState: issue.prReviewState,
245
246
  prCheckStatus: issue.prCheckStatus,
247
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
246
248
  latestFailureCheckName: issue.lastGitHubFailureCheckName,
247
249
  });
248
250
  const lines = [
@@ -33,6 +33,7 @@ function lowerCaseFirst(value) {
33
33
  const WORKFLOW_FILES = {
34
34
  implementation: "IMPLEMENTATION_WORKFLOW.md",
35
35
  review_fix: "REVIEW_WORKFLOW.md",
36
+ branch_upkeep: "REVIEW_WORKFLOW.md",
36
37
  ci_repair: "IMPLEMENTATION_WORKFLOW.md",
37
38
  queue_repair: "IMPLEMENTATION_WORKFLOW.md",
38
39
  };
@@ -151,6 +152,17 @@ function appendLinearContext(lines, context) {
151
152
  lines.push("## Human Follow-up Comment", "", userComment, "");
152
153
  }
153
154
  }
155
+ function isRequestedChangesRunType(runType) {
156
+ return runType === "review_fix" || runType === "branch_upkeep";
157
+ }
158
+ function resolveRequestedChangesMode(runType, context) {
159
+ if (runType === "branch_upkeep") {
160
+ return "branch_upkeep";
161
+ }
162
+ return context?.reviewFixMode === "branch_upkeep" || context?.branchUpkeepRequired === true
163
+ ? "branch_upkeep"
164
+ : "address_review_feedback";
165
+ }
154
166
  function readReviewFixComments(context) {
155
167
  const raw = context?.reviewComments;
156
168
  if (!Array.isArray(raw)) {
@@ -199,7 +211,7 @@ function appendStructuredReviewContext(lines, context) {
199
211
  lines.push("No inline review comments were captured for this review.", "");
200
212
  return;
201
213
  }
202
- lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "");
214
+ lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "A requested-changes turn is only complete if you push a newer PR head or deliberately escalate because you are blocked.", "");
203
215
  for (const comment of reviewComments) {
204
216
  const location = comment.path
205
217
  ? `${comment.path}${comment.line !== undefined ? `:${comment.line}` : ""}${comment.side ? ` (${comment.side})` : ""}`
@@ -239,6 +251,8 @@ function resolveFollowUpWhy(runType, context) {
239
251
  return "An operator supplied new guidance for this issue.";
240
252
  case "review_changes_requested":
241
253
  return "GitHub review requested changes on the current PR head.";
254
+ case "branch_upkeep":
255
+ return "GitHub still shows the PR branch as needing upkeep after the requested code change was addressed.";
242
256
  case "settled_red_ci":
243
257
  return "Required CI settled red for the current PR head.";
244
258
  case "merge_steward_incident":
@@ -248,8 +262,11 @@ function resolveFollowUpWhy(runType, context) {
248
262
  ? "This is the first implementation turn for the delegated issue."
249
263
  : `This turn continues ${runType.replaceAll("_", " ")} work for the delegated issue.`;
250
264
  default:
251
- if (runType === "review_fix")
252
- return "This turn continues requested-changes work on the existing PR.";
265
+ if (isRequestedChangesRunType(runType)) {
266
+ return resolveRequestedChangesMode(runType, context) === "branch_upkeep"
267
+ ? "This turn continues branch upkeep on the existing PR after requested changes."
268
+ : "This turn continues requested-changes work on the existing PR.";
269
+ }
253
270
  if (runType === "ci_repair")
254
271
  return "This turn continues CI repair work on the existing PR.";
255
272
  if (runType === "queue_repair")
@@ -261,13 +278,15 @@ function resolveFollowUpAction(runType, context) {
261
278
  if (context?.directReplyMode === true) {
262
279
  return "Apply the latest human answer, continue from the current branch/session context, and only ask another question if you are still blocked.";
263
280
  }
264
- if (runType === "review_fix" && context?.branchUpkeepRequired === true) {
265
- const baseBranch = typeof context.baseBranch === "string" ? context.baseBranch : "main";
266
- return `Update the existing PR branch onto latest ${baseBranch}, resolve conflicts if needed, rerun narrow verification, and push the same branch.`;
281
+ if (isRequestedChangesRunType(runType) && resolveRequestedChangesMode(runType, context) === "branch_upkeep") {
282
+ const baseBranch = typeof context?.baseBranch === "string" ? context.baseBranch : "main";
283
+ return `Update the existing PR branch onto latest ${baseBranch}, resolve conflicts if needed, rerun narrow verification, and push a newer head on the same branch.`;
267
284
  }
268
285
  switch (runType) {
269
286
  case "review_fix":
270
- return "Address the review feedback on the current PR branch, verify the fix, and push the same branch.";
287
+ return "Address the review feedback on the current PR branch, verify the fix, and push a newer head on the same branch.";
288
+ case "branch_upkeep":
289
+ return "Repair the existing PR branch after requested changes, rerun narrow verification, and push a newer head on the same branch.";
271
290
  case "ci_repair":
272
291
  return "Fix the failing CI root cause on the current PR branch, verify it locally, and push the same branch.";
273
292
  case "queue_repair":
@@ -373,6 +392,15 @@ function appendFollowUpPromptPrelude(lines, issue, runType, context) {
373
392
  appendFactFreshness(lines, issue, runType, context);
374
393
  appendAuthoritativeGitHubFacts(lines, issue, runType, context);
375
394
  }
395
+ function appendRequestedChangesInstructions(lines, runType, context) {
396
+ if (resolveRequestedChangesMode(runType, context) === "branch_upkeep") {
397
+ const baseBranch = typeof context?.baseBranch === "string" ? context.baseBranch : "main";
398
+ lines.push("## Branch Upkeep After Requested Changes", "", "The requested code change may already be present, but the PR branch still needs upkeep before review can continue.", typeof context?.mergeStateStatus === "string" ? `Current merge state: ${String(context.mergeStateStatus)}` : "", "", "Steps:", `1. Update the existing PR branch onto latest ${baseBranch}.`, "2. Resolve conflicts or branch drift without reopening the review-feedback debate unless the merge introduces a new issue.", "3. Run the narrowest verification that proves the branch is healthy again.", "4. Commit and push a newer head on the existing PR branch.", "5. If you cannot produce a new pushed head, stop and surface the exact blocker.", "");
399
+ return;
400
+ }
401
+ 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. Start with the structured review context below. Treat the inline review comments as the primary repair checklist for this turn.", "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 on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit, and push a newer head on the existing PR branch.", "6. Do not try to hand the same head back to review. If you cannot produce a new pushed head, stop and surface the blocker clearly.", "7. GitHub review happens after the new head is pushed and CI is green. Do not use `gh pr edit --add-reviewer` as part of this workflow.", "");
402
+ appendStructuredReviewContext(lines, context);
403
+ }
376
404
  export function buildInitialRunPrompt(issue, runType, repoPath, context) {
377
405
  const lines = buildPromptHeader(issue);
378
406
  appendTaskObjective(lines, issue);
@@ -391,8 +419,8 @@ export function buildInitialRunPrompt(issue, runType, repoPath, context) {
391
419
  break;
392
420
  }
393
421
  case "review_fix":
394
- 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. Start with the structured review context below. Treat the inline review comments as the primary repair checklist for this turn.", "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 on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit and push.", "6. 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.", "");
395
- appendStructuredReviewContext(lines, context);
422
+ case "branch_upkeep":
423
+ appendRequestedChangesInstructions(lines, runType, context);
396
424
  break;
397
425
  case "queue_repair":
398
426
  appendQueueRepairContext(lines, context);
@@ -426,8 +454,8 @@ export function buildFollowUpRunPrompt(issue, runType, repoPath, context) {
426
454
  break;
427
455
  }
428
456
  case "review_fix":
429
- 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. Start with the structured review context below. Treat the inline review comments as the primary repair checklist for this turn.", "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 on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit and push.", "6. 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.", "");
430
- appendStructuredReviewContext(lines, context);
457
+ case "branch_upkeep":
458
+ appendRequestedChangesInstructions(lines, runType, context);
431
459
  break;
432
460
  case "queue_repair":
433
461
  appendQueueRepairContext(lines, context);
@@ -529,9 +557,9 @@ export class RunOrchestrator {
529
557
  eventType = "settled_red_ci";
530
558
  dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
531
559
  }
532
- else if (runType === "review_fix") {
560
+ else if (runType === "review_fix" || runType === "branch_upkeep") {
533
561
  eventType = "review_changes_requested";
534
- dedupeKey = `${dedupeScope ?? "wake"}:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
562
+ dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
535
563
  }
536
564
  else {
537
565
  eventType = "delegated";
@@ -591,10 +619,14 @@ export class RunOrchestrator {
591
619
  return;
592
620
  }
593
621
  const { runType, context, resumeThread } = wake;
594
- const effectiveContext = runType === "review_fix"
595
- ? await this.resolveReviewFixWakeContext(issue, context, project)
622
+ const effectiveContext = isRequestedChangesRunType(runType)
623
+ ? await this.resolveRequestedChangesWakeContext(issue, runType, context, project)
596
624
  : context;
597
- const isReviewFixBranchUpkeep = runType === "review_fix" && isBranchUpkeepRequired(effectiveContext);
625
+ const sourceHeadSha = typeof effectiveContext?.failureHeadSha === "string"
626
+ ? effectiveContext.failureHeadSha
627
+ : typeof effectiveContext?.headSha === "string"
628
+ ? effectiveContext.headSha
629
+ : issue.prHeadSha;
598
630
  // Check repair budgets
599
631
  if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
600
632
  this.escalate(issue, runType, `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`);
@@ -604,7 +636,7 @@ export class RunOrchestrator {
604
636
  this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
605
637
  return;
606
638
  }
607
- if (runType === "review_fix" && !isReviewFixBranchUpkeep && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
639
+ if (runType === "review_fix" && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
608
640
  this.escalate(issue, runType, `Review fix budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
609
641
  return;
610
642
  }
@@ -623,7 +655,7 @@ export class RunOrchestrator {
623
655
  return;
624
656
  }
625
657
  }
626
- if (runType === "review_fix" && !isReviewFixBranchUpkeep) {
658
+ if (runType === "review_fix") {
627
659
  const updated = this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts + 1 });
628
660
  if (!updated) {
629
661
  this.releaseIssueSessionLease(item.projectId, item.issueId);
@@ -652,6 +684,7 @@ export class RunOrchestrator {
652
684
  projectId: item.projectId,
653
685
  linearIssueId: item.issueId,
654
686
  runType,
687
+ ...(sourceHeadSha ? { sourceHeadSha } : {}),
655
688
  promptText: prompt,
656
689
  });
657
690
  const failureHeadSha = typeof effectiveContext?.failureHeadSha === "string"
@@ -668,7 +701,7 @@ export class RunOrchestrator {
668
701
  worktreePath,
669
702
  factoryState: runType === "implementation" ? "implementing"
670
703
  : runType === "ci_repair" ? "repairing_ci"
671
- : runType === "review_fix" ? "changes_requested"
704
+ : runType === "review_fix" || runType === "branch_upkeep" ? "changes_requested"
672
705
  : runType === "queue_repair" ? "repairing_queue"
673
706
  : "implementing",
674
707
  ...((runType === "ci_repair" || runType === "queue_repair") && failureSignature
@@ -772,6 +805,7 @@ export class RunOrchestrator {
772
805
  const message = error instanceof Error ? error.message : String(error);
773
806
  const lostLease = error instanceof Error && error.name === "IssueSessionLeaseLostError";
774
807
  if (!lostLease) {
808
+ const nextState = isRequestedChangesRunType(runType) ? "escalated" : "failed";
775
809
  this.db.finishRunWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, run.id, {
776
810
  status: "failed",
777
811
  failureReason: message,
@@ -780,7 +814,7 @@ export class RunOrchestrator {
780
814
  projectId: item.projectId,
781
815
  linearIssueId: item.issueId,
782
816
  activeRunId: null,
783
- factoryState: "failed",
817
+ factoryState: nextState,
784
818
  });
785
819
  }
786
820
  this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
@@ -949,6 +983,7 @@ export class RunOrchestrator {
949
983
  const completedTurnId = extractTurnId(notification.params);
950
984
  const status = resolveRunCompletionStatus(notification.params);
951
985
  if (status === "failed") {
986
+ const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
952
987
  const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
953
988
  this.db.finishRunWithLease(lease, run.id, {
954
989
  status: "failed",
@@ -960,7 +995,7 @@ export class RunOrchestrator {
960
995
  projectId: run.projectId,
961
996
  linearIssueId: run.linearIssueId,
962
997
  activeRunId: null,
963
- factoryState: "failed",
998
+ factoryState: nextState,
964
999
  });
965
1000
  return true;
966
1001
  });
@@ -1012,6 +1047,26 @@ export class RunOrchestrator {
1012
1047
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1013
1048
  return;
1014
1049
  }
1050
+ const missingReviewFixHeadError = await this.verifyReviewFixAdvancedHead(run, freshIssue);
1051
+ if (missingReviewFixHeadError) {
1052
+ this.failRunAndClear(run, missingReviewFixHeadError, "escalated");
1053
+ const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1054
+ this.feed?.publish({
1055
+ level: "error",
1056
+ kind: "turn",
1057
+ issueKey: freshIssue.issueKey,
1058
+ projectId: run.projectId,
1059
+ stage: run.runType,
1060
+ status: "same_head_review_handoff_blocked",
1061
+ summary: missingReviewFixHeadError,
1062
+ });
1063
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, missingReviewFixHeadError));
1064
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1065
+ this.linearSync.clearProgress(run.id);
1066
+ this.activeThreadId = undefined;
1067
+ this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1068
+ return;
1069
+ }
1015
1070
  const publishedOutcomeError = await this.verifyPublishedRunOutcome(run, freshIssue);
1016
1071
  if (publishedOutcomeError) {
1017
1072
  this.failRunAndClear(run, publishedOutcomeError, "failed");
@@ -1192,6 +1247,36 @@ export class RunOrchestrator {
1192
1247
  const fresh = this.db.getIssue(issue.projectId, issue.linearIssueId);
1193
1248
  if (!fresh)
1194
1249
  return;
1250
+ if (isRequestedChangesRunType(runType)) {
1251
+ const updated = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
1252
+ this.db.clearPendingIssueSessionEventsWithLease(lease);
1253
+ this.db.upsertIssueWithLease(lease, {
1254
+ projectId: fresh.projectId,
1255
+ linearIssueId: fresh.linearIssueId,
1256
+ pendingRunType: null,
1257
+ pendingRunContextJson: null,
1258
+ factoryState: "escalated",
1259
+ });
1260
+ return true;
1261
+ });
1262
+ if (!updated) {
1263
+ this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping review-fix recovery escalation after losing issue-session lease");
1264
+ this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1265
+ return;
1266
+ }
1267
+ this.logger.warn({ issueKey: fresh.issueKey, reason }, "Requested-changes run failed before a new head was published — escalating");
1268
+ this.feed?.publish({
1269
+ level: "error",
1270
+ kind: "workflow",
1271
+ issueKey: fresh.issueKey,
1272
+ projectId: fresh.projectId,
1273
+ stage: runType,
1274
+ status: "escalated",
1275
+ summary: `Requested-changes run failed before publishing a new head (${reason})`,
1276
+ });
1277
+ this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1278
+ return;
1279
+ }
1195
1280
  // If PR already merged, transition to done — no retry needed
1196
1281
  if (fresh.prState === "merged") {
1197
1282
  const updated = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
@@ -1404,6 +1489,25 @@ export class RunOrchestrator {
1404
1489
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1405
1490
  return;
1406
1491
  }
1492
+ if (isRequestedChangesRunType(run.runType)) {
1493
+ const interruptedMessage = "Requested-changes run was interrupted before PatchRelay could verify that a new PR head was published";
1494
+ this.failRunAndClear(run, interruptedMessage, "escalated");
1495
+ 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 });
1508
+ this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1509
+ return;
1510
+ }
1407
1511
  const recoveredState = resolveRecoverablePostRunState(this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
1408
1512
  this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
1409
1513
  await this.restoreIdleWorktree(issue);
@@ -1450,6 +1554,24 @@ export class RunOrchestrator {
1450
1554
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1451
1555
  return;
1452
1556
  }
1557
+ const missingReviewFixHeadError = await this.verifyReviewFixAdvancedHead(run, freshIssue);
1558
+ if (missingReviewFixHeadError) {
1559
+ this.failRunAndClear(run, missingReviewFixHeadError, "escalated");
1560
+ const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1561
+ this.feed?.publish({
1562
+ level: "error",
1563
+ kind: "turn",
1564
+ issueKey: freshIssue.issueKey,
1565
+ projectId: run.projectId,
1566
+ stage: run.runType,
1567
+ status: "same_head_review_handoff_blocked",
1568
+ summary: missingReviewFixHeadError,
1569
+ });
1570
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, missingReviewFixHeadError));
1571
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1572
+ this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1573
+ return;
1574
+ }
1453
1575
  const publishedOutcomeError = await this.verifyPublishedRunOutcome(run, freshIssue);
1454
1576
  if (publishedOutcomeError) {
1455
1577
  this.failRunAndClear(run, publishedOutcomeError, "failed");
@@ -1556,6 +1678,7 @@ export class RunOrchestrator {
1556
1678
  if (issue.activeRunId) {
1557
1679
  this.db.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
1558
1680
  }
1681
+ this.db.clearPendingIssueSessionEventsWithLease(lease);
1559
1682
  this.db.upsertIssueWithLease(lease, {
1560
1683
  projectId: issue.projectId,
1561
1684
  linearIssueId: issue.linearIssueId,
@@ -1589,8 +1712,11 @@ export class RunOrchestrator {
1589
1712
  this.releaseIssueSessionLease(issue.projectId, issue.linearIssueId);
1590
1713
  }
1591
1714
  failRunAndClear(run, message, nextState = "failed") {
1592
- const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
1715
+ const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
1593
1716
  this.db.finishRun(run.id, { status: "failed", failureReason: message });
1717
+ if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
1718
+ this.db.clearPendingIssueSessionEventsWithLease(lease);
1719
+ }
1594
1720
  this.db.upsertIssue({
1595
1721
  projectId: run.projectId,
1596
1722
  linearIssueId: run.linearIssueId,
@@ -1642,8 +1768,43 @@ export class RunOrchestrator {
1642
1768
  return undefined;
1643
1769
  }
1644
1770
  }
1771
+ async verifyReviewFixAdvancedHead(run, issue) {
1772
+ if (!isRequestedChangesRunType(run.runType)) {
1773
+ return undefined;
1774
+ }
1775
+ if (!issue.prNumber || issue.prState !== "open") {
1776
+ return undefined;
1777
+ }
1778
+ if (!run.sourceHeadSha) {
1779
+ return `Requested-changes run finished for PR #${issue.prNumber} without a recorded starting head SHA. PatchRelay cannot verify that a new head was published.`;
1780
+ }
1781
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
1782
+ if (!project?.github?.repoFullName) {
1783
+ return undefined;
1784
+ }
1785
+ try {
1786
+ const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
1787
+ if (!pr || pr.state?.toUpperCase() !== "OPEN")
1788
+ return undefined;
1789
+ if (!pr.headRefOid) {
1790
+ return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
1791
+ }
1792
+ if (pr.headRefOid === run.sourceHeadSha) {
1793
+ return `Requested-changes run finished for PR #${issue.prNumber} without pushing a new head; PatchRelay must not hand the same SHA back to review.`;
1794
+ }
1795
+ return undefined;
1796
+ }
1797
+ catch (error) {
1798
+ this.logger.debug({
1799
+ issueKey: issue.issueKey,
1800
+ prNumber: issue.prNumber,
1801
+ error: error instanceof Error ? error.message : String(error),
1802
+ }, "Failed to verify PR head advancement after requested-changes work");
1803
+ return undefined;
1804
+ }
1805
+ }
1645
1806
  async refreshIssueAfterReactivePublish(run, issue) {
1646
- if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
1807
+ if (run.runType !== "ci_repair" && run.runType !== "queue_repair" && !isRequestedChangesRunType(run.runType)) {
1647
1808
  return this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
1648
1809
  }
1649
1810
  if (!issue.prNumber) {
@@ -1663,13 +1824,15 @@ export class RunOrchestrator {
1663
1824
  const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
1664
1825
  const gateCheckName = project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify";
1665
1826
  const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== issue.lastGitHubFailureHeadSha);
1827
+ const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
1828
+ && Boolean(pr.headRefOid && run.sourceHeadSha && pr.headRefOid !== run.sourceHeadSha);
1666
1829
  this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
1667
1830
  projectId: run.projectId,
1668
1831
  linearIssueId: run.linearIssueId,
1669
1832
  ...(nextPrState ? { prState: nextPrState } : {}),
1670
1833
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
1671
1834
  ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
1672
- ...(headAdvanced
1835
+ ...((headAdvanced || reviewFixHeadAdvanced)
1673
1836
  ? {
1674
1837
  prCheckStatus: "pending",
1675
1838
  lastGitHubFailureSource: null,
@@ -1710,8 +1873,8 @@ export class RunOrchestrator {
1710
1873
  return undefined;
1711
1874
  return JSON.parse(stdout);
1712
1875
  }
1713
- async resolveReviewFixWakeContext(issue, context, project) {
1714
- if (isBranchUpkeepRequired(context)) {
1876
+ async resolveRequestedChangesWakeContext(issue, runType, context, project) {
1877
+ if (runType === "branch_upkeep" || isBranchUpkeepRequired(context)) {
1715
1878
  return context;
1716
1879
  }
1717
1880
  if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
@@ -1747,7 +1910,7 @@ export class RunOrchestrator {
1747
1910
  issueKey: issue.issueKey,
1748
1911
  prNumber: issue.prNumber,
1749
1912
  error: error instanceof Error ? error.message : String(error),
1750
- }, "Failed to resolve review-fix wake context");
1913
+ }, "Failed to resolve requested-changes wake context");
1751
1914
  return context;
1752
1915
  }
1753
1916
  }
@@ -1786,7 +1949,7 @@ export class RunOrchestrator {
1786
1949
  if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
1787
1950
  return undefined;
1788
1951
  return {
1789
- pendingRunType: "review_fix",
1952
+ pendingRunType: "branch_upkeep",
1790
1953
  factoryState: "changes_requested",
1791
1954
  context: buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr),
1792
1955
  summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
@@ -2135,13 +2298,15 @@ function isDirtyMergeStateStatus(value) {
2135
2298
  }
2136
2299
  function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
2137
2300
  const promptContext = [
2138
- `The requested review change is already addressed, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
2139
- `Before stopping, update the existing PR branch onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push again.`,
2140
- "Do not stop just because the requested code change is already present.",
2301
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
2302
+ `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.`,
2303
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
2141
2304
  ].join(" ");
2142
2305
  return {
2143
2306
  ...(context ?? {}),
2144
2307
  branchUpkeepRequired: true,
2308
+ reviewFixMode: "branch_upkeep",
2309
+ wakeReason: "branch_upkeep",
2145
2310
  promptContext,
2146
2311
  ...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
2147
2312
  ...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
package/dist/service.js CHANGED
@@ -362,7 +362,7 @@ export class PatchRelayService {
362
362
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
363
363
  i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
364
364
  i.pending_run_type,
365
- i.pr_number, i.pr_review_state, i.pr_check_status,
365
+ i.pr_number, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
366
366
  i.last_github_ci_snapshot_json,
367
367
  i.last_github_failure_source,
368
368
  i.last_github_failure_head_sha,
@@ -452,8 +452,10 @@ export class PatchRelayService {
452
452
  factoryState: String(row.factory_state ?? "delegated"),
453
453
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
454
454
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
455
+ ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
455
456
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
456
457
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
458
+ ...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
457
459
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
458
460
  });
459
461
  const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
@@ -614,6 +616,7 @@ export class PatchRelayService {
614
616
  return undefined;
615
617
  if (issue.activeRunId)
616
618
  return { error: "Issue already has an active run" };
619
+ const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
617
620
  if (issue.prState === "merged") {
618
621
  this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
619
622
  projectId: issue.projectId,
@@ -634,7 +637,9 @@ export class PatchRelayService {
634
637
  factoryState = "repairing_ci";
635
638
  }
636
639
  else if (issue.prNumber && issue.prReviewState === "changes_requested") {
637
- runType = "review_fix";
640
+ runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
641
+ ? "branch_upkeep"
642
+ : "review_fix";
638
643
  factoryState = "changes_requested";
639
644
  }
640
645
  else if (issue.prNumber) {
@@ -693,16 +698,19 @@ export class PatchRelayService {
693
698
  });
694
699
  return;
695
700
  }
696
- if (runType === "review_fix") {
701
+ if (runType === "review_fix" || runType === "branch_upkeep") {
697
702
  this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
698
703
  projectId: issue.projectId,
699
704
  linearIssueId: issue.linearIssueId,
700
705
  eventType: "review_changes_requested",
701
706
  eventJson: JSON.stringify({
702
- reviewBody: "Operator requested retry of review-fix work.",
707
+ reviewBody: runType === "branch_upkeep"
708
+ ? "Operator requested retry of branch upkeep after requested changes."
709
+ : "Operator requested retry of review-fix work.",
710
+ ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
703
711
  source: "operator_retry",
704
712
  }),
705
- dedupeKey: `operator_retry:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
713
+ dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
706
714
  });
707
715
  return;
708
716
  }
@@ -2,7 +2,8 @@ export const PATCHRELAY_WAITING_REASONS = {
2
2
  activeWork: "PatchRelay is actively working",
3
3
  waitingForOperatorInput: "Waiting on operator input",
4
4
  waitingForReviewFeedback: "Waiting to address review feedback",
5
- waitingForRereview: "Waiting on re-review after requested changes",
5
+ waitingForReviewOnNewHead: "Waiting on review of a newer pushed head",
6
+ sameHeadStillBlocked: "Requested changes still block the current head",
6
7
  waitingForMergeStewardRepair: "Waiting to repair a merge-steward incident",
7
8
  waitingForDownstreamAutomation: "Waiting on downstream review/merge automation",
8
9
  workComplete: "PatchRelay work is complete",
@@ -45,7 +46,12 @@ export function derivePatchRelayWaitingReason(params) {
45
46
  }
46
47
  if (params.prReviewState === "changes_requested") {
47
48
  if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
48
- return PATCHRELAY_WAITING_REASONS.waitingForRereview;
49
+ if (params.prHeadSha
50
+ && params.lastBlockingReviewHeadSha
51
+ && params.prHeadSha !== params.lastBlockingReviewHeadSha) {
52
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
53
+ }
54
+ return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
49
55
  }
50
56
  return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
51
57
  }