patchrelay 0.23.3 → 0.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.23.3",
4
- "commit": "1c8c52702f63",
5
- "builtAt": "2026-03-26T13:46:43.445Z"
3
+ "version": "0.23.4",
4
+ "commit": "650e9c85fdcf",
5
+ "builtAt": "2026-03-26T13:49:43.718Z"
6
6
  }
@@ -20,8 +20,12 @@ export const TERMINAL_STATES = new Set([
20
20
  const isOpen = (s) => !TERMINAL_STATES.has(s);
21
21
  const TRANSITION_RULES = [
22
22
  // ── Terminal events ────────────────────────────────────────────
23
- // pr_merged is unconditional PR is merged, we're done.
23
+ // pr_merged transitions to done only when no agent run is active.
24
+ // If an active run exists, suppress the transition — the run's
25
+ // completion handler will detect the merged PR and advance to done.
26
+ // This prevents orphaning agent work (e.g. pending follow-up fixes).
24
27
  { event: "pr_merged",
28
+ guard: (_, ctx) => ctx.activeRunId === undefined,
25
29
  to: "done" },
26
30
  // pr_closed during an active run is suppressed — Codex may reopen.
27
31
  // Without a guard match, the event produces no transition (undefined).
@@ -163,6 +163,12 @@ export class GitHubWebhookHandler {
163
163
  this.mergeQueue.advanceQueue(issue.projectId);
164
164
  }
165
165
  }
166
+ // Advance the merge queue even when the state transition was suppressed
167
+ // (e.g., pr_merged during an active run). The PR is factually merged —
168
+ // the next queued issue should not wait for the active run to finish.
169
+ if (!newState && event.triggerEvent === "pr_merged") {
170
+ this.mergeQueue.advanceQueue(issue.projectId);
171
+ }
166
172
  }
167
173
  // Re-read issue after all upserts so reactive run logic sees current state
168
174
  const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -3,7 +3,8 @@ import { execCommand } from "./utils.js";
3
3
  const DEFAULT_MERGE_PREP_BUDGET = 3;
4
4
  /**
5
5
  * Merge queue steward — keeps PatchRelay-managed PR branches up to date
6
- * with the base branch and enables auto-merge so GitHub merges when CI passes.
6
+ * with the base branch via rebase and enables auto-merge so GitHub merges
7
+ * when CI passes. Uses rebase (not merge) to maintain linear history.
7
8
  *
8
9
  * Serialization: all calls are routed through the issue queue, and
9
10
  * prepareForMerge checks front-of-queue before acting. The issue processor
@@ -28,10 +29,10 @@ export class MergeQueue {
28
29
  /**
29
30
  * Prepare the front-of-queue issue for merge:
30
31
  * 1. Enable auto-merge
31
- * 2. Update the branch to latest base (git merge)
32
- * 3. Push (triggers CI; auto-merge fires when CI passes)
32
+ * 2. Rebase the branch onto latest base
33
+ * 3. Force-push (triggers CI; auto-merge fires when CI passes)
33
34
  *
34
- * On conflict: abort merge, transition to repairing_queue, enqueue queue_repair.
35
+ * On conflict: abort rebase, transition to repairing_queue, enqueue queue_repair.
35
36
  * On transient failure: leave pendingMergePrep set so the next event retries.
36
37
  */
37
38
  async prepareForMerge(issue, project) {
@@ -88,20 +89,41 @@ export class MergeQueue {
88
89
  this.onLinearActivity?.(issue, buildMergePrepActivity("fetch_retry"), { ephemeral: true });
89
90
  return;
90
91
  }
91
- // Merge base branch into the PR branch
92
- const mergeResult = await execCommand(gitBin, ["-C", issue.worktreePath, "merge", `origin/${baseBranch}`, "--no-edit"], {
93
- timeoutMs: 60_000,
92
+ // Check if rebase is needed: is HEAD already on top of origin/baseBranch?
93
+ const mergeBaseResult = await execCommand(gitBin, ["-C", issue.worktreePath, "merge-base", "--is-ancestor", `origin/${baseBranch}`, "HEAD"], {
94
+ timeoutMs: 10_000,
95
+ });
96
+ if (mergeBaseResult.exitCode === 0) {
97
+ this.logger.debug({ issueKey: issue.issueKey }, "Merge prep: branch already up to date");
98
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
99
+ if (!autoMergeOk) {
100
+ this.feed?.publish({
101
+ level: "warn",
102
+ kind: "workflow",
103
+ issueKey: issue.issueKey,
104
+ projectId: issue.projectId,
105
+ stage: "awaiting_queue",
106
+ status: "blocked",
107
+ summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
108
+ });
109
+ this.onLinearActivity?.(issue, buildMergePrepActivity("blocked"));
110
+ }
111
+ return;
112
+ }
113
+ // Rebase onto latest base branch (clean linear history, no merge commits)
114
+ const rebaseResult = await execCommand(gitBin, ["-C", issue.worktreePath, "rebase", `origin/${baseBranch}`], {
115
+ timeoutMs: 120_000,
94
116
  });
95
- if (mergeResult.exitCode !== 0) {
117
+ if (rebaseResult.exitCode !== 0) {
96
118
  // Conflict — abort and trigger queue_repair
97
- await execCommand(gitBin, ["-C", issue.worktreePath, "merge", "--abort"], { timeoutMs: 10_000 });
98
- this.logger.info({ issueKey: issue.issueKey }, "Merge prep: conflict detected, triggering queue repair");
119
+ await execCommand(gitBin, ["-C", issue.worktreePath, "rebase", "--abort"], { timeoutMs: 10_000 });
120
+ this.logger.info({ issueKey: issue.issueKey }, "Merge prep: rebase conflict detected, triggering queue repair");
99
121
  this.db.upsertIssue({
100
122
  projectId: issue.projectId,
101
123
  linearIssueId: issue.linearIssueId,
102
124
  factoryState: "repairing_queue",
103
125
  pendingRunType: "queue_repair",
104
- pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
126
+ pendingRunContextJson: JSON.stringify({ failureReason: "rebase_conflict" }),
105
127
  pendingMergePrep: false,
106
128
  });
107
129
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
@@ -112,31 +134,13 @@ export class MergeQueue {
112
134
  projectId: issue.projectId,
113
135
  stage: "repairing_queue",
114
136
  status: "conflict",
115
- summary: `Merge conflict with ${baseBranch} — queue repair enqueued`,
137
+ summary: `Rebase conflict with ${baseBranch} — queue repair enqueued`,
116
138
  });
117
139
  this.onLinearActivity?.(issue, buildMergePrepActivity("conflict"));
118
140
  return;
119
141
  }
120
- // Check if merge was a no-op (already up to date)
121
- if (mergeResult.stdout?.includes("Already up to date")) {
122
- this.logger.debug({ issueKey: issue.issueKey }, "Merge prep: branch already up to date");
123
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
124
- if (!autoMergeOk) {
125
- this.feed?.publish({
126
- level: "warn",
127
- kind: "workflow",
128
- issueKey: issue.issueKey,
129
- projectId: issue.projectId,
130
- stage: "awaiting_queue",
131
- status: "blocked",
132
- summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
133
- });
134
- this.onLinearActivity?.(issue, buildMergePrepActivity("blocked"));
135
- }
136
- return;
137
- }
138
- // Push the merged branch
139
- const pushResult = await execCommand(gitBin, ["-C", issue.worktreePath, "push"], {
142
+ // Push the rebased branch (force-with-lease to protect against concurrent changes)
143
+ const pushResult = await execCommand(gitBin, ["-C", issue.worktreePath, "push", "--force-with-lease"], {
140
144
  timeoutMs: 60_000,
141
145
  });
142
146
  if (pushResult.exitCode !== 0) {
@@ -145,7 +149,7 @@ export class MergeQueue {
145
149
  this.onLinearActivity?.(issue, buildMergePrepActivity("push_retry"), { ephemeral: true });
146
150
  return;
147
151
  }
148
- this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Merge prep: branch updated and pushed");
152
+ this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Merge prep: rebased and pushed");
149
153
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
150
154
  this.feed?.publish({
151
155
  level: "info",
@@ -154,7 +158,7 @@ export class MergeQueue {
154
158
  projectId: issue.projectId,
155
159
  stage: "awaiting_queue",
156
160
  status: "prepared",
157
- summary: `Branch updated to latest ${baseBranch} — CI will run`,
161
+ summary: `Branch rebased onto latest ${baseBranch} — CI will run`,
158
162
  });
159
163
  this.onLinearActivity?.(issue, buildMergePrepActivity("branch_update", baseBranch), { ephemeral: true });
160
164
  }
@@ -622,6 +622,15 @@ export class RunOrchestrator {
622
622
  factoryState: "done",
623
623
  });
624
624
  });
625
+ this.feed?.publish({
626
+ level: "info",
627
+ kind: "stage",
628
+ issueKey: issue.issueKey,
629
+ projectId: run.projectId,
630
+ stage: "done",
631
+ status: "reconciled",
632
+ summary: `Linear state ${stopState.stateName} \u2192 done`,
633
+ });
625
634
  return;
626
635
  }
627
636
  }
@@ -670,6 +679,17 @@ export class RunOrchestrator {
670
679
  ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
671
680
  });
672
681
  });
682
+ if (postRunState) {
683
+ this.feed?.publish({
684
+ level: "info",
685
+ kind: "turn",
686
+ issueKey: issue.issueKey,
687
+ projectId: run.projectId,
688
+ stage: run.runType,
689
+ status: "completed",
690
+ summary: `Reconciliation: ${run.runType} completed \u2192 ${postRunState}`,
691
+ });
692
+ }
673
693
  if (postRunState === "awaiting_queue") {
674
694
  this.enqueueIssue(run.projectId, run.linearIssueId);
675
695
  }
package/dist/service.js CHANGED
@@ -342,6 +342,15 @@ export class PatchRelayService {
342
342
  pendingRunType: runType,
343
343
  factoryState: factoryState,
344
344
  });
345
+ this.feed.publish({
346
+ level: "info",
347
+ kind: "stage",
348
+ issueKey: issue.issueKey,
349
+ projectId: issue.projectId,
350
+ stage: factoryState,
351
+ status: "retry",
352
+ summary: `Retry queued: ${runType}`,
353
+ });
345
354
  this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
346
355
  return { issueKey, runType };
347
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.23.3",
3
+ "version": "0.23.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {