patchrelay 0.71.1 → 0.72.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.
@@ -51,14 +51,6 @@ function ciRepairPlan(attempt) {
51
51
  { content: "Merge", status: "pending" },
52
52
  ];
53
53
  }
54
- function mainRepairPlan(attempt) {
55
- return [
56
- { content: "Inspect main failure", status: "pending" },
57
- { content: `Repairing main (${attemptLabel(attempt)})`, status: "pending" },
58
- { content: "Fresh head pushed", status: "pending" },
59
- { content: "Priority merge", status: "pending" },
60
- ];
61
- }
62
54
  function queueRepairPlan(attempt) {
63
55
  return [
64
56
  { content: "Prepare workspace", status: "completed" },
@@ -148,9 +140,7 @@ export function buildAgentSessionPlan(params) {
148
140
  case "delegated":
149
141
  return setStatuses(planForRunType(runType, params), ["inProgress", "pending", "pending", "pending"]);
150
142
  case "implementing":
151
- return setStatuses(params.activeRunType === "main_repair" || params.pendingRunType === "main_repair"
152
- ? mainRepairPlan(params.ciRepairAttempts ?? 1)
153
- : planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
143
+ return setStatuses(planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
154
144
  case "pr_open":
155
145
  return setStatuses([
156
146
  { content: "Prepare workspace", status: "completed" },
@@ -217,8 +207,6 @@ function normalizeState(value) {
217
207
  }
218
208
  function planForRunType(runType, params) {
219
209
  switch (runType) {
220
- case "main_repair":
221
- return mainRepairPlan(params.ciRepairAttempts ?? 1);
222
210
  case "review_fix":
223
211
  return reviewFixPlan();
224
212
  case "branch_upkeep":
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.71.1",
4
- "commit": "a52a4e7f91af",
5
- "builtAt": "2026-05-23T22:11:42.868Z"
3
+ "version": "0.72.0",
4
+ "commit": "d4d672824c5f",
5
+ "builtAt": "2026-05-24T01:19:45.865Z"
6
6
  }
@@ -16,6 +16,7 @@ function formatDuration(startedAt, endedAt) {
16
16
  }
17
17
  const RUN_LABELS = {
18
18
  implementation: "implementation",
19
+ // main_repair is a removed run type; label retained to render historical runs.
19
20
  main_repair: "main repair",
20
21
  ci_repair: "ci repair",
21
22
  review_fix: "review fix",
@@ -2,6 +2,7 @@ import { relativeTime, truncate } from "./format-utils.js";
2
2
  export { relativeTime };
3
3
  const RUN_LABEL = {
4
4
  implementation: "implementation",
5
+ // main_repair is a removed run type; label retained to render historical runs.
5
6
  main_repair: "main repair",
6
7
  ci_repair: "ci repair",
7
8
  review_fix: "review fix",
@@ -2,6 +2,7 @@
2
2
  const SIDE_TRIP_STATES = new Set(["changes_requested", "repairing_ci", "repairing_queue"]);
3
3
  const RUN_TYPE_TO_STATE = {
4
4
  implementation: "implementing",
5
+ // main_repair is a removed run type; label retained to render historical runs.
5
6
  main_repair: "implementing",
6
7
  ci_repair: "repairing_ci",
7
8
  review_fix: "changes_requested",
package/dist/config.js CHANGED
@@ -102,6 +102,7 @@ const promptLayerSchema = z.object({
102
102
  });
103
103
  const promptByRunTypeSchema = z.object({
104
104
  implementation: promptLayerSchema.optional(),
105
+ // main_repair is a removed run type; key retained (optional) so pre-existing configs still validate.
105
106
  main_repair: promptLayerSchema.optional(),
106
107
  review_fix: promptLayerSchema.optional(),
107
108
  branch_upkeep: promptLayerSchema.optional(),
@@ -1,5 +1,4 @@
1
1
  import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
2
- import { isMainRepairIssue } from "./main-repair.js";
3
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
4
3
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
5
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
@@ -342,35 +341,6 @@ export class IdleIssueReconciler {
342
341
  const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
343
342
  return resolveMergeQueueProtocol(project);
344
343
  }
345
- async ensurePriorityQueueLabel(issue, repoFullName) {
346
- if (!isMainRepairIssue(issue) || !issue.prNumber)
347
- return;
348
- const priorityLabel = this.getIssueProtocol(issue).priorityLabel;
349
- try {
350
- const { stdout } = await execCommand("gh", [
351
- "pr", "view", String(issue.prNumber),
352
- "--repo", repoFullName,
353
- "--json", "labels",
354
- ], { timeoutMs: 10_000 });
355
- const payload = JSON.parse(stdout);
356
- if ((payload.labels ?? []).some((entry) => entry.name === priorityLabel)) {
357
- return;
358
- }
359
- await execCommand("gh", [
360
- "pr", "edit", String(issue.prNumber),
361
- "--repo", repoFullName,
362
- "--add-label", priorityLabel,
363
- ], { timeoutMs: 10_000 });
364
- }
365
- catch (error) {
366
- this.logger.warn({
367
- issueKey: issue.issueKey,
368
- prNumber: issue.prNumber,
369
- priorityLabel,
370
- error: error instanceof Error ? error.message : String(error),
371
- }, "Reconciliation: failed to enforce priority queue label");
372
- }
373
- }
374
344
  async reconcileFromGitHub(issue) {
375
345
  const project = this.config.projects.find((p) => p.id === issue.projectId);
376
346
  if (!project?.github?.repoFullName || !issue.prNumber)
@@ -390,9 +360,6 @@ export class IdleIssueReconciler {
390
360
  const previousHeadSha = issue.prHeadSha;
391
361
  const gateCheckNames = getGateCheckNames(project);
392
362
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
393
- if (pr.state === "OPEN") {
394
- await this.ensurePriorityQueueLabel(issue, project.github.repoFullName);
395
- }
396
363
  this.db.issues.upsertIssue({
397
364
  projectId: issue.projectId,
398
365
  linearIssueId: issue.linearIssueId,
@@ -1,5 +1,3 @@
1
- import { isMainRepairIssue } from "./main-repair.js";
2
- import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
1
  import { execCommand } from "./utils.js";
4
2
  export class ImplementationOutcomePolicy {
5
3
  config;
@@ -13,7 +11,7 @@ export class ImplementationOutcomePolicy {
13
11
  this.withHeldLease = withHeldLease;
14
12
  }
15
13
  async verifyPublishedRunOutcome(run, issue) {
16
- if (run.runType !== "implementation" && run.runType !== "main_repair") {
14
+ if (run.runType !== "implementation") {
17
15
  return undefined;
18
16
  }
19
17
  const project = this.config.projects.find((entry) => entry.id === run.projectId);
@@ -26,7 +24,7 @@ export class ImplementationOutcomePolicy {
26
24
  return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
27
25
  }
28
26
  async detectRecoverableFailedImplementationOutcome(run, issue) {
29
- if (run.runType !== "implementation" && run.runType !== "main_repair") {
27
+ if (run.runType !== "implementation") {
30
28
  return undefined;
31
29
  }
32
30
  const project = this.config.projects.find((entry) => entry.id === run.projectId);
@@ -81,9 +79,6 @@ export class ImplementationOutcomePolicy {
81
79
  else {
82
80
  this.clearObservedPrIfLeaseHeld(issue, "published PR verification found only historical PRs for branch");
83
81
  }
84
- if (isOpenPrState(state) && isMainRepairIssue(issue)) {
85
- await this.ensurePriorityQueueLabel(run.projectId, pr.number, repoFullName);
86
- }
87
82
  return isOpenPrState(state) ? "open" : "closed";
88
83
  }
89
84
  catch (error) {
@@ -158,44 +153,6 @@ export class ImplementationOutcomePolicy {
158
153
  }
159
154
  return undefined;
160
155
  }
161
- async ensurePriorityQueueLabel(projectId, prNumber, repoFullName) {
162
- const project = this.config.projects.find((entry) => entry.id === projectId);
163
- if (!project || !repoFullName)
164
- return;
165
- const priorityLabel = resolveMergeQueueProtocol(project).priorityLabel;
166
- try {
167
- const { stdout } = await execCommand("gh", [
168
- "pr",
169
- "view",
170
- String(prNumber),
171
- "--repo",
172
- repoFullName,
173
- "--json",
174
- "labels",
175
- ], { timeoutMs: 10_000 });
176
- const labels = JSON.parse(stdout);
177
- const hasLabel = (labels.labels ?? []).some((entry) => entry.name === priorityLabel);
178
- if (hasLabel)
179
- return;
180
- await execCommand("gh", [
181
- "pr",
182
- "edit",
183
- String(prNumber),
184
- "--repo",
185
- repoFullName,
186
- "--add-label",
187
- priorityLabel,
188
- ], { timeoutMs: 10_000 });
189
- }
190
- catch (error) {
191
- this.logger.warn({
192
- projectId,
193
- prNumber,
194
- priorityLabel,
195
- error: error instanceof Error ? error.message : String(error),
196
- }, "Failed to enforce priority queue label on main repair PR");
197
- }
198
- }
199
156
  }
200
157
  function isOpenPrState(state) {
201
158
  if (!state)
@@ -12,7 +12,10 @@ const NON_ACTIONABLE_SESSION_EVENTS = new Set([
12
12
  "prompt_delivered",
13
13
  "run_released_authority",
14
14
  ]);
15
- const RUN_TYPES = new Set(["implementation", "main_repair", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
15
+ // "main_repair" was removed as a run type; legacy session-event payloads carrying it
16
+ // are not in this set, so parseRunType returns undefined and callers fall back to
17
+ // "implementation" (see deriveSessionWakePlan below).
18
+ const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
16
19
  function parseRunType(value) {
17
20
  return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
18
21
  }
@@ -53,9 +56,7 @@ export function deriveSessionWakePlan(issue, events) {
53
56
  case "delegated":
54
57
  if (!runType) {
55
58
  runType = parseRunType(payload?.runType) ?? "implementation";
56
- wakeReason = runType === "main_repair"
57
- ? "main_repair"
58
- : issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
59
+ wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
59
60
  }
60
61
  Object.assign(context, payload ?? {});
61
62
  break;
@@ -18,8 +18,6 @@ export function deriveIssueSessionWakeReason(params) {
18
18
  return undefined;
19
19
  if (params.pendingRunType === "implementation")
20
20
  return "delegated";
21
- if (params.pendingRunType === "main_repair")
22
- return "main_repair";
23
21
  if (params.pendingRunType === "review_fix")
24
22
  return "review_changes_requested";
25
23
  if (params.pendingRunType === "branch_upkeep")
@@ -1,4 +1,4 @@
1
- import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredDeployLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredMergeQueueLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
3
  import { isCompletedLinearState } from "./pr-state.js";
4
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
@@ -67,24 +67,21 @@ async function syncQueuedForDeployLabel(params) {
67
67
  await linear.updateIssueLabels({ issueId: issue.linearIssueId, removeNames: [labelName] });
68
68
  }
69
69
  }
70
- // True only when (a) the issue is In Deploy AND (b) the project's
71
- // Linear workflow has no In Deploy-equivalent state — detected by the
72
- // preferred-deploying state collapsing to the same name as the
73
- // preferred-review state. When the project does have a real In Deploy
74
- // state, `setIssueState` flows the issue there and the label is
75
- // unnecessary.
70
+ // True only when (a) the issue is in the merge queue (`awaiting_queue`)
71
+ // AND (b) the project's Linear workflow has no dedicated In Merge Queue
72
+ // state — detected by the preferred merge-queue state collapsing to the
73
+ // same name as the reviewing state. When the project has a real In Merge
74
+ // Queue (or Deploying) state, `setIssueState` flows the issue there and
75
+ // the label is unnecessary.
76
76
  function isQueuedForDeployFallback(issue, liveIssue) {
77
77
  if (issue.factoryState !== "awaiting_queue")
78
78
  return false;
79
- const deploying = resolvePreferredDeployingLinearState(liveIssue);
80
- const review = resolvePreferredReviewLinearState(liveIssue);
81
- const deployUnstarted = resolvePreferredDeployLinearState(liveIssue);
82
- if (!deploying || !review)
79
+ const mergeQueue = resolvePreferredMergeQueueLinearState(liveIssue);
80
+ const reviewing = resolvePreferredReviewingLinearState(liveIssue);
81
+ if (!mergeQueue || !reviewing)
83
82
  return false;
84
- // No "deploying"/"deploy" state in the workflow both resolve to
85
- // a review state. That's the fallback condition.
86
- return deploying.trim().toLowerCase() === review.trim().toLowerCase()
87
- && (deployUnstarted ?? "").trim().toLowerCase() === review.trim().toLowerCase();
83
+ // No dedicated merge-queue state it collapses to the reviewing state.
84
+ return mergeQueue.trim().toLowerCase() === reviewing.trim().toLowerCase();
88
85
  }
89
86
  async function syncCompletedLinearState(params) {
90
87
  const { db, issue, linear, liveIssue } = params;
@@ -121,49 +118,115 @@ function shouldAutoAdvanceLinearState(issue) {
121
118
  const normalizedName = issue.currentLinearState?.trim().toLowerCase();
122
119
  return normalizedName !== "done" && normalizedName !== "completed" && normalizedName !== "complete";
123
120
  }
124
- function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue) {
125
- if (issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated"
126
- || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
121
+ // ─── Unified PR-lifecycle Linear-state mapping ─────────────────────
122
+ //
123
+ // Five phases, in lifecycle order:
124
+ // Implementing → Reviewing → In Merge Queue → Deploying → Done
125
+ //
126
+ // Every phase is decided from DURABLE signals (factoryState, prState,
127
+ // prReviewState) — never the ephemeral activeRunId / sessionState / run
128
+ // type. That is what kills the Implementing↔Reviewing flap: the state
129
+ // only moves on a real lifecycle handoff (a review verdict, an approval,
130
+ // a merge), not on whichever transient webhook happens to recompute it
131
+ // while a run briefly holds a lease.
132
+ //
133
+ // Branches are ordered "furthest along the lifecycle wins" so a stale
134
+ // earlier signal can never pull a more-advanced issue backwards.
135
+ function resolveDesiredActiveWorkflowState(issue, trackedIssue, _options, liveIssue) {
136
+ // 1. Operator must act — overrides everything.
137
+ if (needsHumanAttention(issue, trackedIssue)) {
127
138
  return resolvePreferredHumanNeededLinearState(liveIssue);
128
139
  }
140
+ // 2. Completed → Done. Covers today's merge→done path (the factory has
141
+ // no post-merge state yet), so a done issue never reads as Deploying.
142
+ if (issue.factoryState === "done") {
143
+ return resolvePreferredCompletedLinearState(liveIssue);
144
+ }
145
+ // 3. Paused with no PR and nothing for us to do → backlog.
129
146
  const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
130
- const pausedNoPrWork = issue.prNumber === undefined && (!issue.delegatedToPatchRelay || blocked);
131
- if (pausedNoPrWork) {
147
+ const noPr = issue.prNumber === undefined && !issue.prUrl;
148
+ if (noPr && (issue.delegatedToPatchRelay === false || blocked)) {
132
149
  return resolvePreferredQueuedLinearState(liveIssue);
133
150
  }
134
- const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
135
- || options?.activeRunType !== undefined
136
- || trackedIssue?.sessionState === "running"
137
- || (issue.factoryState === "delegated" && !blocked && trackedIssue?.readyForExecution !== false)
138
- || issue.factoryState === "implementing"
139
- || issue.factoryState === "changes_requested"
140
- || issue.factoryState === "repairing_ci"
141
- || issue.factoryState === "repairing_queue");
142
- if (activelyWorking) {
151
+ // 4. Post-merge: the change is on main, deploy running → Deploying.
152
+ // Durable signal: the PR is merged. (Until PR3 makes merge a
153
+ // non-terminal phase this only fires in the merged-not-yet-done
154
+ // window; branch 2 already caught factoryState==="done".)
155
+ if (normalize(issue.prState) === "merged") {
156
+ return resolvePreferredDeployingLinearState(liveIssue);
157
+ }
158
+ // 5. Patchrelay is actively addressing review/CI/queue feedback →
159
+ // Implementing. These factory states persist for the run's whole
160
+ // duration, so this is stable, not flappy — and it is exactly the
161
+ // "show when patchrelay handles feedback" behavior we want.
162
+ if (isAddressingFeedback(issue)) {
143
163
  return resolvePreferredImplementingLinearState(liveIssue);
144
164
  }
145
- if (issue.factoryState === "awaiting_queue"
146
- || issue.prReviewState === "approved"
147
- || isApprovedAndGreen(issue.prReviewState, issue.prCheckStatus)) {
148
- return resolvePreferredDeployingLinearState(liveIssue);
165
+ // 6. Approved / admitted to the merge queue → In Merge Queue.
166
+ if (isInMergeQueue(issue)) {
167
+ return resolvePreferredMergeQueueLinearState(liveIssue);
149
168
  }
150
- if (hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson)) {
169
+ // 7. Pre-review-feedback implementation work (incl. a draft PR)
170
+ // Implementing.
171
+ if (isImplementing(issue, trackedIssue)) {
172
+ return resolvePreferredImplementingLinearState(liveIssue);
173
+ }
174
+ // 8. PR exists and is under review → Reviewing.
175
+ if (isReviewBound(issue)) {
151
176
  return resolvePreferredReviewingLinearState(liveIssue);
152
177
  }
153
- const reviewBound = issue.prNumber !== undefined
178
+ return undefined;
179
+ }
180
+ function normalize(value) {
181
+ const trimmed = value?.trim().toLowerCase();
182
+ return trimmed ? trimmed : undefined;
183
+ }
184
+ function needsHumanAttention(issue, trackedIssue) {
185
+ return issue.factoryState === "awaiting_input"
186
+ || issue.factoryState === "failed"
187
+ || issue.factoryState === "escalated"
188
+ || trackedIssue?.sessionState === "waiting_input"
189
+ || trackedIssue?.sessionState === "failed";
190
+ }
191
+ // Active code work to address feedback. Durable factory states +
192
+ // changes-requested review verdict — no run-id involvement. Gated on
193
+ // delegation: an undelegated PR (operator paused us) is not being worked
194
+ // by patchrelay, so it must not read as Implementing.
195
+ function isAddressingFeedback(issue) {
196
+ if (issue.delegatedToPatchRelay === false)
197
+ return false;
198
+ return issue.factoryState === "changes_requested"
199
+ || issue.factoryState === "repairing_ci"
200
+ || issue.factoryState === "repairing_queue"
201
+ || normalize(issue.prReviewState) === "changes_requested";
202
+ }
203
+ // Approved and heading to / sitting in the merge queue. Not yet merged
204
+ // (branch 4 catches merged first).
205
+ function isInMergeQueue(issue) {
206
+ return issue.factoryState === "awaiting_queue"
207
+ || normalize(issue.prReviewState) === "approved";
208
+ }
209
+ // Initial implementation, before review starts. A draft PR still counts
210
+ // as implementing. Gated on delegation so we never claim Implementing
211
+ // for work that isn't ours.
212
+ function isImplementing(issue, trackedIssue) {
213
+ if (issue.delegatedToPatchRelay === false)
214
+ return false;
215
+ if (issue.factoryState === "implementing")
216
+ return true;
217
+ if (issue.factoryState === "delegated") {
218
+ const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
219
+ return !blocked && trackedIssue?.readyForExecution !== false;
220
+ }
221
+ return issue.prIsDraft === true;
222
+ }
223
+ function isReviewBound(issue) {
224
+ return issue.prNumber !== undefined
154
225
  || Boolean(issue.prUrl)
155
226
  || issue.factoryState === "pr_open"
156
227
  || issue.prReviewState !== undefined
157
- || issue.prCheckStatus !== undefined;
158
- if (reviewBound) {
159
- return resolvePreferredReviewLinearState(liveIssue);
160
- }
161
- return undefined;
162
- }
163
- function isApprovedAndGreen(prReviewState, prCheckStatus) {
164
- const normalizedReview = prReviewState?.trim().toLowerCase();
165
- const normalizedChecks = prCheckStatus?.trim().toLowerCase();
166
- return normalizedReview === "approved" && (normalizedChecks === "success" || normalizedChecks === "passed");
228
+ || issue.prCheckStatus !== undefined
229
+ || hasPendingReviewQuillVerdict(issue.lastGitHubCiSnapshotJson);
167
230
  }
168
231
  function hasPendingReviewQuillVerdict(snapshotJson) {
169
232
  if (!snapshotJson)
@@ -59,16 +59,37 @@ export function resolvePreferredReviewingLinearState(issue) {
59
59
  fallback: resolvePreferredReviewLinearState(issue),
60
60
  });
61
61
  }
62
+ // The pre-merge "approved, awaiting/undergoing landing" phase. Covers a
63
+ // PR that is queued, being tested in the speculative branch, or actively
64
+ // merging — i.e. everything the merge queue owns up to (but not past)
65
+ // the merge. NOT post-merge: that is `resolvePreferredDeployingLinearState`.
66
+ // Without a dedicated queue state, collapses to the reviewing state (and
67
+ // the `queued-for-deploy` label disambiguates — see state-sync).
68
+ export function resolvePreferredMergeQueueLinearState(issue) {
69
+ return resolvePreferredLinearState(issue, {
70
+ names: ["in merge queue", "merge queue", "in queue", "queued", "queue", "merging", "landing", "ready to merge"],
71
+ types: ["started"],
72
+ fallback: resolvePreferredLinearState(issue, {
73
+ names: ["in merge queue", "merge queue", "queued", "ready to merge", "ready to deploy", "ready for deploy", "to deploy", "merge"],
74
+ types: ["unstarted"],
75
+ fallback: resolvePreferredReviewingLinearState(issue),
76
+ }),
77
+ });
78
+ }
79
+ // Unstarted deploy column, used as a fallback by the started variant.
62
80
  export function resolvePreferredDeployLinearState(issue) {
63
81
  return resolvePreferredLinearState(issue, {
64
- names: ["deploy", "ready to deploy", "ready for deploy", "merge"],
82
+ names: ["deploy", "to deploy", "ready to ship"],
65
83
  types: ["unstarted"],
66
- fallback: resolvePreferredReviewLinearState(issue),
84
+ fallback: resolvePreferredMergeQueueLinearState(issue),
67
85
  });
68
86
  }
87
+ // Strictly POST-merge: the change is on main and the deploy workflow is
88
+ // running. "merging" lives in the merge-queue phase, not here. Without a
89
+ // dedicated deploy state, collapses back to the merge-queue state.
69
90
  export function resolvePreferredDeployingLinearState(issue) {
70
91
  return resolvePreferredLinearState(issue, {
71
- names: ["deploying", "merging", "shipping"],
92
+ names: ["deploying", "deployment", "in deploy", "shipping", "releasing", "rollout"],
72
93
  types: ["started"],
73
94
  fallback: resolvePreferredDeployLinearState(issue),
74
95
  });
@@ -168,50 +168,6 @@ export async function handleNoPrCompletionCheck(params) {
168
168
  });
169
169
  return;
170
170
  }
171
- if (params.run.runType === "main_repair") {
172
- const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
173
- params.db.runs.finishRun(params.run.id, runUpdate);
174
- params.db.runs.saveCompletionCheck(params.run.id, {
175
- ...completionCheck,
176
- outcome: "continue",
177
- summary: "Main repair cannot finish without a published repair PR; continuing automatically until the fix is published or main recovers externally.",
178
- why: completionCheck.summary,
179
- });
180
- params.db.issues.upsertIssue({
181
- projectId: params.run.projectId,
182
- linearIssueId: params.run.linearIssueId,
183
- activeRunId: null,
184
- factoryState: "delegated",
185
- pendingRunType: null,
186
- pendingRunContextJson: null,
187
- });
188
- return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
189
- projectId: params.run.projectId,
190
- linearIssueId: params.run.linearIssueId,
191
- eventType: "completion_check_continue",
192
- eventJson: JSON.stringify({
193
- runType: params.run.runType,
194
- summary: params.publishedOutcomeError,
195
- }),
196
- dedupeKey: `completion_check_continue:${params.run.id}`,
197
- }));
198
- });
199
- if (!continued) {
200
- params.logger.warn({ runId: params.run.id, issueId: params.run.linearIssueId }, "Skipping main-repair completion-check continue writes after losing issue-session lease");
201
- params.clearProgressAndRelease(params.run);
202
- return;
203
- }
204
- params.syncCompletionCheckOutcome({
205
- run: params.run,
206
- fallbackIssue: params.issue,
207
- level: "info",
208
- status: "completion_check_continue",
209
- summary: "No repair PR found; continuing automatically",
210
- detail: "Main repair cannot close until PatchRelay publishes a repair PR or main recovers externally.",
211
- activity: buildCompletionCheckActivity("continue"),
212
- });
213
- return;
214
- }
215
171
  const orchestrationOpenChildren = params.issue.issueClass === "orchestration"
216
172
  ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
217
173
  : 0;
@@ -7,6 +7,7 @@ const promptLayerSchema = z.object({
7
7
  });
8
8
  const promptByRunTypeSchema = z.object({
9
9
  implementation: promptLayerSchema.optional(),
10
+ // main_repair is a removed run type; key retained (optional) so pre-existing configs still validate.
10
11
  main_repair: promptLayerSchema.optional(),
11
12
  review_fix: promptLayerSchema.optional(),
12
13
  branch_upkeep: promptLayerSchema.optional(),
@@ -3,7 +3,6 @@ import path from "node:path";
3
3
  import { derivePrDisplayContext } from "../pr-display-context.js";
4
4
  const WORKFLOW_FILES = {
5
5
  implementation: "IMPLEMENTATION_WORKFLOW.md",
6
- main_repair: "IMPLEMENTATION_WORKFLOW.md",
7
6
  review_fix: "REVIEW_WORKFLOW.md",
8
7
  branch_upkeep: "REVIEW_WORKFLOW.md",
9
8
  ci_repair: "IMPLEMENTATION_WORKFLOW.md",
@@ -346,23 +345,6 @@ function buildCiRepairContext(context) {
346
345
  : "",
347
346
  ].filter(Boolean).join("\n");
348
347
  }
349
- function buildMainRepairContext(context) {
350
- const failingCheckNames = Array.isArray(context?.failingChecks)
351
- ? context.failingChecks
352
- .filter((entry) => Boolean(entry) && typeof entry === "object")
353
- .map((entry) => String(entry.name ?? "").trim())
354
- .filter((name) => name.length > 0)
355
- : [];
356
- return [
357
- "Base-branch repair on the red mainline.",
358
- "Goal: restore main by fixing the real persistent failure, not by papering over a transient runner incident.",
359
- "Before changing code or workflow config, verify that the original incident still persists on the exact failing main SHA or identify a concrete log signature that justifies the fix.",
360
- "For transient infrastructure symptoms such as disk pressure, runner exhaustion, or network flakiness, prefer a rerun-only repair if the rerun clears the branch.",
361
- "Do not propose or implement moving CI, deploy, or tests onto different nodes or runner pools unless a human explicitly asked for that infrastructure migration.",
362
- context?.baseSha ? `Failing main SHA: ${String(context.baseSha)}` : "",
363
- failingCheckNames.length > 0 ? `Failing checks: ${failingCheckNames.join(", ")}` : "",
364
- ].filter(Boolean).join("\n");
365
- }
366
348
  function appendQueueRepairContext(lines, context) {
367
349
  const queueContext = context?.mergeQueueContext;
368
350
  if (!queueContext || typeof queueContext !== "object") {
@@ -489,9 +471,6 @@ function buildCurrentContext(runType, issue, context, followUp = false) {
489
471
  }
490
472
  lines.push(...buildHumanContextLines(context));
491
473
  switch (runType) {
492
- case "main_repair":
493
- lines.push(buildMainRepairContext(context));
494
- break;
495
474
  case "ci_repair":
496
475
  lines.push(buildCiRepairContext(context));
497
476
  break;
@@ -125,11 +125,10 @@ export class RunLauncher {
125
125
  branchName: params.branchName,
126
126
  worktreePath: params.worktreePath,
127
127
  factoryState: params.runType === "implementation" ? "implementing"
128
- : params.runType === "main_repair" ? "implementing"
129
- : params.runType === "ci_repair" ? "repairing_ci"
130
- : params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
131
- : params.runType === "queue_repair" ? "repairing_queue"
132
- : "implementing",
128
+ : params.runType === "ci_repair" ? "repairing_ci"
129
+ : params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
130
+ : params.runType === "queue_repair" ? "repairing_queue"
131
+ : "implementing",
133
132
  ...((params.runType === "ci_repair" || params.runType === "queue_repair") && failureSignature
134
133
  ? {
135
134
  lastAttemptedFailureSignature: failureSignature,
@@ -4,7 +4,6 @@ import { CompletionCheckService } from "./completion-check.js";
4
4
  import { PublicationRecapService } from "./publication-recap.js";
5
5
  import { WorktreeManager } from "./worktree-manager.js";
6
6
  import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
7
- import { MainBranchHealthMonitor } from "./main-branch-health-monitor.js";
8
7
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
9
8
  import { IdleIssueReconciler } from "./idle-reconciliation.js";
10
9
  import { LinearSessionSync } from "./linear-session-sync.js";
@@ -47,7 +46,6 @@ export class RunOrchestrator {
47
46
  linearProvider;
48
47
  enqueueIssue;
49
48
  worktreeManager;
50
- mainBranchHealthMonitor;
51
49
  /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
52
50
  queueHealthMonitor;
53
51
  idleReconciler;
@@ -137,7 +135,6 @@ export class RunOrchestrator {
137
135
  this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
138
136
  this.runWakePlanner = new RunWakePlanner(db);
139
137
  this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed);
140
- this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, logger, feed);
141
138
  this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
142
139
  this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
143
140
  advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
@@ -454,7 +451,6 @@ export class RunOrchestrator {
454
451
  for (const run of this.db.runs.listRunningRuns()) {
455
452
  await this.reconcileRun(run);
456
453
  }
457
- await this.mainBranchHealthMonitor.reconcile();
458
454
  // Preemptively detect stuck merge-queue PRs (conflicts visible on
459
455
  // GitHub) and dispatch queue_repair before the Steward evicts.
460
456
  await this.queueHealthMonitor.reconcile();
@@ -27,10 +27,6 @@ export class RunWakePlanner {
27
27
  eventType = "settled_red_ci";
28
28
  dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
29
29
  }
30
- else if (runType === "main_repair") {
31
- eventType = "delegated";
32
- dedupeKey = `${dedupeScope ?? "wake"}:main_repair:${issue.linearIssueId}`;
33
- }
34
30
  else if (runType === "review_fix" || runType === "branch_upkeep") {
35
31
  eventType = "review_changes_requested";
36
32
  dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,179 +0,0 @@
1
- import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
2
- import { buildMainRepairBranchName, isMainRepairIssue, } from "./main-repair.js";
3
- import { execCommand } from "./utils.js";
4
- const MAIN_BRANCH_HEALTH_GRACE_MS = 120_000;
5
- function isUnhealthyMainConclusion(conclusion) {
6
- return conclusion === "failure"
7
- || conclusion === "timed_out"
8
- || conclusion === "cancelled"
9
- || conclusion === "action_required"
10
- || conclusion === "stale";
11
- }
12
- export class MainBranchHealthMonitor {
13
- db;
14
- config;
15
- linearProvider;
16
- logger;
17
- feed;
18
- /** Per-project throttle for the information-only "main is red" log. */
19
- lastUnhealthyReportAt = new Map();
20
- constructor(db, config, linearProvider, logger, feed) {
21
- this.db = db;
22
- this.config = config;
23
- this.linearProvider = linearProvider;
24
- this.logger = logger;
25
- this.feed = feed;
26
- }
27
- async reconcile() {
28
- for (const project of this.config.projects) {
29
- await this.reconcileProject(project.id);
30
- }
31
- }
32
- async reconcileProject(projectId) {
33
- const project = this.config.projects.find((entry) => entry.id === projectId);
34
- if (!project?.github?.repoFullName)
35
- return;
36
- if (project.linearTeamIds.length === 0)
37
- return;
38
- const baseBranch = project.github.baseBranch ?? "main";
39
- const branchName = buildMainRepairBranchName(baseBranch);
40
- const existing = this.findExistingMainRepair(projectId, branchName);
41
- const summary = await this.readMainBranchFailure(project.github.repoFullName, baseBranch);
42
- if (!summary) {
43
- if (existing) {
44
- await this.resolveRecoveredMainRepair(existing);
45
- }
46
- return;
47
- }
48
- // main CI is red. The merge queue (merge-steward) gates only on its own
49
- // speculative-SHA checks and ignores main entirely, so a red main no longer
50
- // warrants an automated repair job — main CI is information-only. Report it
51
- // (throttled) and post nothing. Any pre-existing repair issue is left to close
52
- // via resolveRecoveredMainRepair once main recovers.
53
- this.reportUnhealthyMain(projectId, project.github.repoFullName, baseBranch, summary);
54
- }
55
- reportUnhealthyMain(projectId, repoFullName, baseBranch, summary) {
56
- const now = Date.now();
57
- const lastReportedAt = this.lastUnhealthyReportAt.get(projectId);
58
- if (lastReportedAt !== undefined && now - lastReportedAt < MAIN_BRANCH_HEALTH_GRACE_MS) {
59
- return;
60
- }
61
- this.lastUnhealthyReportAt.set(projectId, now);
62
- this.logger.warn({
63
- projectId,
64
- repoFullName,
65
- baseBranch,
66
- baseSha: summary.baseSha,
67
- failingChecks: summary.failingChecks.map((check) => check.name),
68
- }, "main branch CI is red — information only; no repair job posted (merge queue gates on its own spec CI)");
69
- }
70
- findExistingMainRepair(projectId, branchName) {
71
- const candidates = this.db.listIssues()
72
- .filter((issue) => (issue.projectId === projectId
73
- && issue.branchName === branchName
74
- && isMainRepairIssue(issue)
75
- && issue.factoryState !== "done"))
76
- .sort((left, right) => this.compareMainRepairCandidates(left, right));
77
- return candidates[0];
78
- }
79
- compareMainRepairCandidates(left, right) {
80
- const leftPriority = this.rankMainRepairCandidate(left);
81
- const rightPriority = this.rankMainRepairCandidate(right);
82
- if (leftPriority !== rightPriority)
83
- return leftPriority - rightPriority;
84
- return Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
85
- }
86
- rankMainRepairCandidate(issue) {
87
- if (issue.activeRunId !== undefined)
88
- return 0;
89
- if (issue.prState === "open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "pr_open")
90
- return 1;
91
- if (issue.factoryState === "delegated" || issue.factoryState === "implementing")
92
- return 2;
93
- if (issue.factoryState === "failed" || issue.factoryState === "escalated")
94
- return 3;
95
- return 4;
96
- }
97
- async resolveRecoveredMainRepair(issue) {
98
- if (issue.activeRunId !== undefined)
99
- return;
100
- if (issue.prState === "open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "pr_open") {
101
- return;
102
- }
103
- const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
104
- if (linear) {
105
- const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
106
- if (liveIssue) {
107
- const targetState = resolvePreferredCompletedLinearState(liveIssue);
108
- const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
109
- if (targetState && normalizedCurrent !== targetState.trim().toLowerCase()) {
110
- const updated = await linear.setIssueState(issue.linearIssueId, targetState).catch(() => undefined);
111
- if (updated) {
112
- this.db.upsertIssue({
113
- projectId: issue.projectId,
114
- linearIssueId: issue.linearIssueId,
115
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
116
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
117
- });
118
- }
119
- }
120
- else {
121
- this.db.upsertIssue({
122
- projectId: issue.projectId,
123
- linearIssueId: issue.linearIssueId,
124
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
125
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
126
- });
127
- }
128
- }
129
- }
130
- this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
131
- this.db.upsertIssue({
132
- projectId: issue.projectId,
133
- linearIssueId: issue.linearIssueId,
134
- factoryState: "done",
135
- pendingRunType: null,
136
- });
137
- this.feed?.publish({
138
- level: "info",
139
- kind: "github",
140
- issueKey: issue.issueKey,
141
- projectId: issue.projectId,
142
- stage: "done",
143
- status: "main_repair_resolved",
144
- summary: "Closed stale main_repair after main recovered externally",
145
- });
146
- }
147
- async readMainBranchFailure(repoFullName, baseBranch) {
148
- const { stdout: shaOut } = await execCommand("gh", [
149
- "api",
150
- `repos/${repoFullName}/branches/${baseBranch}`,
151
- "--jq",
152
- ".commit.sha",
153
- ], { timeoutMs: 10_000 });
154
- const baseSha = shaOut.trim();
155
- if (!baseSha)
156
- return undefined;
157
- const { stdout: checksOut } = await execCommand("gh", [
158
- "api",
159
- `repos/${repoFullName}/commits/${baseSha}/check-runs`,
160
- "--jq",
161
- ".check_runs",
162
- ], { timeoutMs: 10_000 });
163
- const runs = JSON.parse(checksOut || "[]");
164
- const failingChecks = runs
165
- .filter((run) => run.status === "completed" && isUnhealthyMainConclusion(run.conclusion) && typeof run.name === "string" && run.name.trim())
166
- .map((run) => ({ name: run.name.trim(), ...(run.details_url ? { url: run.details_url } : {}) }));
167
- if (failingChecks.length === 0) {
168
- return undefined;
169
- }
170
- const pendingChecks = runs
171
- .filter((run) => run.status !== "completed" && typeof run.name === "string" && run.name.trim())
172
- .map((run) => ({ name: run.name.trim(), ...(run.details_url ? { url: run.details_url } : {}) }));
173
- return {
174
- baseSha,
175
- failingChecks,
176
- pendingChecks,
177
- };
178
- }
179
- }
@@ -1,47 +0,0 @@
1
- export const MAIN_REPAIR_BRANCH_PREFIX = "main-repair";
2
- export function buildMainRepairBranchName(baseBranch) {
3
- return `${MAIN_REPAIR_BRANCH_PREFIX}/${baseBranch}`;
4
- }
5
- export function isMainRepairIssue(issue) {
6
- return typeof issue.branchName === "string" && issue.branchName.startsWith(`${MAIN_REPAIR_BRANCH_PREFIX}/`);
7
- }
8
- export function buildMainRepairTitle(project) {
9
- const repo = project.github?.repoFullName ?? project.id;
10
- const baseBranch = project.github?.baseBranch ?? "main";
11
- return `Repair ${baseBranch} for ${repo}`;
12
- }
13
- export function buildMainRepairDescription(project, summary, priorityLabel) {
14
- const repo = project.github?.repoFullName ?? project.id;
15
- const baseBranch = project.github?.baseBranch ?? "main";
16
- const lines = [
17
- `Automatically created because \`${repo}@${baseBranch}\` is red.`,
18
- "",
19
- `Base SHA: \`${summary.baseSha}\``,
20
- "",
21
- "Repair the base-branch failure on a PR branch, get the PR green, and keep it in the priority queue lane.",
22
- `The repair PR must carry the GitHub label \`${priorityLabel}\`.`,
23
- ];
24
- if (summary.failingChecks.length > 0) {
25
- lines.push("", "Failing checks:");
26
- for (const check of summary.failingChecks) {
27
- lines.push(`- ${check.name}${check.url ? ` — ${check.url}` : ""}`);
28
- }
29
- }
30
- if (summary.pendingChecks.length > 0) {
31
- lines.push("", "Pending checks:");
32
- for (const check of summary.pendingChecks) {
33
- lines.push(`- ${check.name}${check.url ? ` — ${check.url}` : ""}`);
34
- }
35
- }
36
- return lines.join("\n");
37
- }
38
- export function buildMainRepairPromptContext(project, summary, priorityLabel) {
39
- const repo = project.github?.repoFullName ?? project.id;
40
- const baseBranch = project.github?.baseBranch ?? "main";
41
- const failingNames = summary.failingChecks.map((check) => check.name).join(", ") || "unknown failing checks";
42
- return [
43
- `Main repair for ${repo}.`,
44
- `${baseBranch} is red at ${summary.baseSha}.`,
45
- `Fix the failing base-branch checks (${failingNames}), publish a PR on this branch, and assign the GitHub label ${priorityLabel}.`,
46
- ].join(" ");
47
- }