patchrelay 0.35.16 → 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.
- package/README.md +11 -2
- package/dist/agent-session-plan.js +14 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +1 -0
- package/dist/cli/cluster-health.js +739 -0
- package/dist/cli/commands/cluster.js +14 -0
- package/dist/cli/data.js +9 -5
- package/dist/cli/help.js +21 -0
- package/dist/cli/index.js +27 -2
- package/dist/cli/output.js +38 -0
- package/dist/cli/watch/StateHistoryView.js +1 -0
- package/dist/cli/watch/TimelineRow.js +1 -0
- package/dist/cli/watch/detail-rows.js +1 -0
- package/dist/cli/watch/history-builder.js +1 -0
- package/dist/db/migrations.js +9 -0
- package/dist/db.js +32 -8
- package/dist/github-webhook-handler.js +5 -78
- package/dist/idle-reconciliation.js +88 -6
- package/dist/issue-query-service.js +2 -0
- package/dist/issue-session-events.js +2 -2
- package/dist/issue-session.js +2 -0
- package/dist/linear-session-reporting.js +2 -0
- package/dist/linear-session-sync.js +2 -0
- package/dist/run-orchestrator.js +196 -31
- package/dist/service.js +13 -5
- package/dist/waiting-reason.js +8 -2
- package/dist/webhook-handler.js +71 -13
- package/package.json +1 -1
package/dist/run-orchestrator.js
CHANGED
|
@@ -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
|
|
252
|
-
return
|
|
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
|
|
265
|
-
const baseBranch = typeof context
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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"}
|
|
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
|
|
595
|
-
? await this.
|
|
622
|
+
const effectiveContext = isRequestedChangesRunType(runType)
|
|
623
|
+
? await this.resolveRequestedChangesWakeContext(issue, runType, context, project)
|
|
596
624
|
: context;
|
|
597
|
-
const
|
|
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" &&
|
|
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"
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
2139
|
-
`
|
|
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 = "
|
|
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:
|
|
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
|
|
713
|
+
dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
|
|
706
714
|
});
|
|
707
715
|
return;
|
|
708
716
|
}
|
package/dist/waiting-reason.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|