patchrelay 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
4
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
4
5
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
5
6
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
6
7
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
@@ -56,9 +57,17 @@ function buildRunPrompt(issue, runType, repoPath, context) {
56
57
  }
57
58
  // Add run-type-specific context for reactive runs
58
59
  switch (runType) {
59
- case "ci_repair":
60
- lines.push("## CI Repair", "", "A CI check has failed on your PR. Fix the failure and push.", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", "", "Read the CI failure logs, fix the code issue, run verification, commit and push.", "Do not change test expectations unless the test is genuinely wrong.", "");
60
+ case "ci_repair": {
61
+ const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
62
+ ? context.ciSnapshot
63
+ : undefined;
64
+ lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Diagnose the whole snapshot, fix the root cause and directly related fallout, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
65
+ ? `All failed checks in settled snapshot:\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
66
+ : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
67
+ ? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
68
+ : "", "", "Read the latest CI logs, consider the broader PR context, fix the likely root cause and any directly related fallout in one pass, run verification, commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
61
69
  break;
70
+ }
62
71
  case "review_fix":
63
72
  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. Read the review feedback and PR comments (`gh pr view --comments`).", "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, note why. If not, fix it.", "4. Run verification, commit and push.", "5. 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.", "");
64
73
  break;
@@ -155,6 +164,10 @@ export class RunOrchestrator {
155
164
  runType,
156
165
  promptText: prompt,
157
166
  });
167
+ const failureHeadSha = typeof context?.failureHeadSha === "string"
168
+ ? context.failureHeadSha
169
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
170
+ const failureSignature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
158
171
  this.db.upsertIssue({
159
172
  projectId: item.projectId,
160
173
  linearIssueId: item.issueId,
@@ -168,6 +181,12 @@ export class RunOrchestrator {
168
181
  : runType === "review_fix" ? "changes_requested"
169
182
  : runType === "queue_repair" ? "repairing_queue"
170
183
  : "implementing",
184
+ ...((runType === "ci_repair" || runType === "queue_repair") && failureSignature
185
+ ? {
186
+ lastAttemptedFailureSignature: failureSignature,
187
+ lastAttemptedFailureHeadSha: failureHeadSha ?? null,
188
+ }
189
+ : {}),
171
190
  });
172
191
  return created;
173
192
  });
@@ -402,6 +421,26 @@ export class RunOrchestrator {
402
421
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
403
422
  // Determine post-run state based on current PR metadata.
404
423
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
424
+ const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
425
+ if (verifiedRepairError) {
426
+ const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
427
+ this.failRunAndClear(run, verifiedRepairError, holdState);
428
+ const heldIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
429
+ this.feed?.publish({
430
+ level: "warn",
431
+ kind: "turn",
432
+ issueKey: freshIssue.issueKey,
433
+ projectId: run.projectId,
434
+ stage: run.runType,
435
+ status: "branch_not_advanced",
436
+ summary: verifiedRepairError,
437
+ });
438
+ void this.emitLinearActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
439
+ void this.syncLinearSession(heldIssue, { activeRunType: run.runType });
440
+ this.progressThrottle.delete(run.id);
441
+ this.activeThreadId = undefined;
442
+ return;
443
+ }
405
444
  const postRunState = resolvePostRunState(freshIssue);
406
445
  this.db.transaction(() => {
407
446
  this.db.finishRun(run.id, {
@@ -419,10 +458,15 @@ export class RunOrchestrator {
419
458
  ...(postRunState === "awaiting_queue" || postRunState === "done"
420
459
  ? {
421
460
  lastGitHubFailureSource: null,
461
+ lastGitHubFailureHeadSha: null,
462
+ lastGitHubFailureSignature: null,
422
463
  lastGitHubFailureCheckName: null,
423
464
  lastGitHubFailureCheckUrl: null,
465
+ lastGitHubFailureContextJson: null,
424
466
  lastGitHubFailureAt: null,
425
467
  lastQueueIncidentJson: null,
468
+ lastAttemptedFailureHeadSha: null,
469
+ lastAttemptedFailureSignature: null,
426
470
  }
427
471
  : {}),
428
472
  });
@@ -534,8 +578,11 @@ export class RunOrchestrator {
534
578
  // Checks failed + idle — route based on durable GitHub failure provenance.
535
579
  if (issue.prCheckStatus === "failed") {
536
580
  if (issue.lastGitHubFailureSource === "queue_eviction") {
537
- if (issue.factoryState !== "repairing_queue") {
538
- const pendingRunContext = buildFailureContext(issue);
581
+ const pendingRunContext = buildFailureContext(issue);
582
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
583
+ this.advanceIdleIssue(issue, "repairing_queue");
584
+ }
585
+ else {
539
586
  this.advanceIdleIssue(issue, "repairing_queue", {
540
587
  pendingRunType: "queue_repair",
541
588
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -544,8 +591,11 @@ export class RunOrchestrator {
544
591
  continue;
545
592
  }
546
593
  if (issue.lastGitHubFailureSource === "branch_ci") {
547
- if (issue.factoryState !== "repairing_ci") {
548
- const pendingRunContext = buildFailureContext(issue);
594
+ const pendingRunContext = buildFailureContext(issue);
595
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
596
+ this.advanceIdleIssue(issue, "repairing_ci");
597
+ }
598
+ else {
549
599
  this.advanceIdleIssue(issue, "repairing_ci", {
550
600
  pendingRunType: "ci_repair",
551
601
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -566,8 +616,11 @@ export class RunOrchestrator {
566
616
  });
567
617
  continue;
568
618
  }
569
- if (issue.factoryState !== "repairing_ci") {
570
- const pendingRunContext = buildFailureContext(issue);
619
+ const pendingRunContext = buildFailureContext(issue);
620
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
621
+ this.advanceIdleIssue(issue, "repairing_ci");
622
+ }
623
+ else {
571
624
  this.advanceIdleIssue(issue, "repairing_ci", {
572
625
  pendingRunType: "ci_repair",
573
626
  ...(pendingRunContext ? { pendingRunContext } : {}),
@@ -606,6 +659,9 @@ export class RunOrchestrator {
606
659
  }
607
660
  }
608
661
  advanceIdleIssue(issue, newState, options) {
662
+ if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
663
+ return;
664
+ }
609
665
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
610
666
  this.db.upsertIssue({
611
667
  projectId: issue.projectId,
@@ -620,10 +676,15 @@ export class RunOrchestrator {
620
676
  ...(options?.clearFailureProvenance
621
677
  ? {
622
678
  lastGitHubFailureSource: null,
679
+ lastGitHubFailureHeadSha: null,
680
+ lastGitHubFailureSignature: null,
623
681
  lastGitHubFailureCheckName: null,
624
682
  lastGitHubFailureCheckUrl: null,
683
+ lastGitHubFailureContextJson: null,
625
684
  lastGitHubFailureAt: null,
626
685
  lastQueueIncidentJson: null,
686
+ lastAttemptedFailureHeadSha: null,
687
+ lastAttemptedFailureSignature: null,
627
688
  }
628
689
  : {}),
629
690
  });
@@ -790,7 +851,7 @@ export class RunOrchestrator {
790
851
  else if (run.runType === "review_fix" && issue.reviewFixAttempts > 0) {
791
852
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts - 1 });
792
853
  }
793
- const recoveredState = resolvePostRunState(this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
854
+ const recoveredState = resolveRecoverablePostRunState(this.db.getIssue(run.projectId, run.linearIssueId) ?? issue);
794
855
  this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
795
856
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
796
857
  if (recoveredState) {
@@ -815,6 +876,21 @@ export class RunOrchestrator {
815
876
  const trackedIssue = this.db.issueToTrackedIssue(issue);
816
877
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
817
878
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
879
+ const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
880
+ if (verifiedRepairError) {
881
+ const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
882
+ this.failRunAndClear(run, verifiedRepairError, holdState);
883
+ this.feed?.publish({
884
+ level: "warn",
885
+ kind: "turn",
886
+ issueKey: issue.issueKey,
887
+ projectId: run.projectId,
888
+ stage: run.runType,
889
+ status: "branch_not_advanced",
890
+ summary: verifiedRepairError,
891
+ });
892
+ return;
893
+ }
818
894
  const postRunState = resolvePostRunState(freshIssue);
819
895
  this.db.transaction(() => {
820
896
  this.db.finishRun(run.id, {
@@ -832,10 +908,15 @@ export class RunOrchestrator {
832
908
  ...(postRunState === "awaiting_queue" || postRunState === "done"
833
909
  ? {
834
910
  lastGitHubFailureSource: null,
911
+ lastGitHubFailureHeadSha: null,
912
+ lastGitHubFailureSignature: null,
835
913
  lastGitHubFailureCheckName: null,
836
914
  lastGitHubFailureCheckUrl: null,
915
+ lastGitHubFailureContextJson: null,
837
916
  lastGitHubFailureAt: null,
838
917
  lastQueueIncidentJson: null,
918
+ lastAttemptedFailureHeadSha: null,
919
+ lastAttemptedFailureSignature: null,
839
920
  }
840
921
  : {}),
841
922
  });
@@ -908,6 +989,41 @@ export class RunOrchestrator {
908
989
  });
909
990
  });
910
991
  }
992
+ async verifyReactiveRunAdvancedBranch(run, issue) {
993
+ if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
994
+ return undefined;
995
+ }
996
+ if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
997
+ return undefined;
998
+ }
999
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
1000
+ if (!project?.github?.repoFullName) {
1001
+ return undefined;
1002
+ }
1003
+ try {
1004
+ const { stdout, exitCode } = await execCommand("gh", [
1005
+ "pr", "view", String(issue.prNumber),
1006
+ "--repo", project.github.repoFullName,
1007
+ "--json", "headRefOid,state",
1008
+ ], { timeoutMs: 10_000 });
1009
+ if (exitCode !== 0)
1010
+ return undefined;
1011
+ const pr = JSON.parse(stdout);
1012
+ if (pr.state?.toUpperCase() !== "OPEN")
1013
+ return undefined;
1014
+ if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
1015
+ return undefined;
1016
+ return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
1017
+ }
1018
+ catch (error) {
1019
+ this.logger.debug({
1020
+ issueKey: issue.issueKey,
1021
+ prNumber: issue.prNumber,
1022
+ error: error instanceof Error ? error.message : String(error),
1023
+ }, "Failed to verify PR head advancement after repair");
1024
+ return undefined;
1025
+ }
1026
+ }
911
1027
  async emitLinearActivity(issue, content, options) {
912
1028
  if (!issue.agentSessionId)
913
1029
  return;
@@ -1025,23 +1141,59 @@ function resolvePostRunState(issue) {
1025
1141
  }
1026
1142
  return undefined;
1027
1143
  }
1144
+ function resolveRecoverablePostRunState(issue) {
1145
+ if (!issue.prNumber) {
1146
+ return resolvePostRunState(issue);
1147
+ }
1148
+ if (issue.prState === "merged")
1149
+ return "done";
1150
+ if (issue.prState === "open") {
1151
+ if (issue.lastGitHubFailureSource === "queue_eviction")
1152
+ return "repairing_queue";
1153
+ if (issue.prCheckStatus === "failed" || issue.lastGitHubFailureSource === "branch_ci")
1154
+ return "repairing_ci";
1155
+ if (issue.prReviewState === "changes_requested")
1156
+ return "changes_requested";
1157
+ if (issue.prReviewState === "approved")
1158
+ return "awaiting_queue";
1159
+ return "pr_open";
1160
+ }
1161
+ return resolvePostRunState(issue);
1162
+ }
1028
1163
  function buildFailureContext(issue) {
1164
+ const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
1029
1165
  const queueRepairContext = issue.lastQueueIncidentJson
1030
1166
  ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
1031
1167
  : undefined;
1032
1168
  if (!queueRepairContext
1033
1169
  && !issue.lastGitHubFailureSource
1170
+ && !issue.lastGitHubFailureHeadSha
1171
+ && !issue.lastGitHubFailureSignature
1034
1172
  && !issue.lastGitHubFailureCheckName
1035
- && !issue.lastGitHubFailureCheckUrl) {
1173
+ && !issue.lastGitHubFailureCheckUrl
1174
+ && !storedFailureContext) {
1036
1175
  return undefined;
1037
1176
  }
1038
1177
  return {
1039
1178
  ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
1179
+ ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
1180
+ ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
1040
1181
  ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
1041
1182
  ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
1183
+ ...(storedFailureContext ? storedFailureContext : {}),
1042
1184
  ...(queueRepairContext ? queueRepairContext : {}),
1043
1185
  };
1044
1186
  }
1187
+ function isDuplicateRepairAttempt(issue, context) {
1188
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
1189
+ const headSha = typeof context?.failureHeadSha === "string"
1190
+ ? context.failureHeadSha
1191
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
1192
+ if (!signature)
1193
+ return false;
1194
+ return issue.lastAttemptedFailureSignature === signature
1195
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
1196
+ }
1045
1197
  function appendQueueRepairContext(lines, context) {
1046
1198
  const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
1047
1199
  const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
package/dist/service.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
2
+ import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
2
3
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
3
4
  import { IssueQueryService } from "./issue-query-service.js";
4
5
  import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
@@ -216,6 +217,10 @@ export class PatchRelayService {
216
217
  i.current_linear_state, i.factory_state, i.updated_at,
217
218
  i.pending_run_type,
218
219
  i.pr_number, i.pr_review_state, i.pr_check_status,
220
+ i.last_github_failure_source,
221
+ i.last_github_failure_head_sha,
222
+ i.last_github_failure_check_name,
223
+ i.last_github_failure_context_json,
219
224
  active_run.run_type AS active_run_type,
220
225
  latest_run.run_type AS latest_run_type,
221
226
  latest_run.status AS latest_run_status,
@@ -257,13 +262,22 @@ export class PatchRelayService {
257
262
  ORDER BY i.updated_at DESC, i.issue_key ASC`)
258
263
  .all();
259
264
  return rows.map((row) => {
265
+ const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
260
266
  const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
261
267
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
262
268
  const blockedByCount = Number(row.blocked_by_count ?? 0);
263
269
  const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
270
+ const failureSummary = summarizeGitHubFailureContext(failureContext);
271
+ const derivedStatusNote = blockedByCount > 0
272
+ ? `Blocked by ${blockedByKeys.join(", ")}`
273
+ : failureSummary && (row.factory_state === "repairing_ci"
274
+ || row.factory_state === "repairing_queue"
275
+ || row.factory_state === "failed")
276
+ ? failureSummary
277
+ : statusNote;
264
278
  const statusNoteWithBlockers = blockedByCount > 0
265
279
  ? `Blocked by ${blockedByKeys.join(", ")}`
266
- : statusNote;
280
+ : derivedStatusNote;
267
281
  return {
268
282
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
269
283
  ...(row.title !== null ? { title: String(row.title) } : {}),
@@ -281,6 +295,11 @@ export class PatchRelayService {
281
295
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
282
296
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
283
297
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
298
+ ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
299
+ ...(row.last_github_failure_head_sha !== null ? { latestFailureHeadSha: String(row.last_github_failure_head_sha) } : {}),
300
+ ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
301
+ ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
302
+ ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
284
303
  updatedAt: String(row.updated_at),
285
304
  };
286
305
  });
@@ -417,7 +436,7 @@ export class PatchRelayService {
417
436
  // Infer run type from current state instead of always resetting to implementation
418
437
  let runType = "implementation";
419
438
  let factoryState = "delegated";
420
- if (issue.prNumber && issue.prCheckStatus === "failed") {
439
+ if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
421
440
  runType = "ci_repair";
422
441
  factoryState = "repairing_ci";
423
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {