patchrelay 0.23.2 → 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.
- package/dist/build-info.json +3 -3
- package/dist/factory-state.js +5 -1
- package/dist/github-webhook-handler.js +6 -0
- package/dist/merge-queue.js +38 -34
- package/dist/run-orchestrator.js +92 -3
- package/dist/service.js +9 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/factory-state.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/merge-queue.js
CHANGED
|
@@ -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
|
|
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.
|
|
32
|
-
* 3.
|
|
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
|
|
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
|
-
//
|
|
92
|
-
const
|
|
93
|
-
timeoutMs:
|
|
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 (
|
|
117
|
+
if (rebaseResult.exitCode !== 0) {
|
|
96
118
|
// Conflict — abort and trigger queue_repair
|
|
97
|
-
await execCommand(gitBin, ["-C", issue.worktreePath, "
|
|
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: "
|
|
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: `
|
|
137
|
+
summary: `Rebase conflict with ${baseBranch} — queue repair enqueued`,
|
|
116
138
|
});
|
|
117
139
|
this.onLinearActivity?.(issue, buildMergePrepActivity("conflict"));
|
|
118
140
|
return;
|
|
119
141
|
}
|
|
120
|
-
//
|
|
121
|
-
|
|
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:
|
|
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
|
|
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
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -56,7 +56,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
|
|
|
56
56
|
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.", "");
|
|
57
57
|
break;
|
|
58
58
|
case "review_fix":
|
|
59
|
-
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)}` : "", "", "Read the review feedback and PR comments (`gh pr view --comments`),
|
|
59
|
+
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.", "");
|
|
60
60
|
break;
|
|
61
61
|
case "queue_repair":
|
|
62
62
|
lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.", "");
|
|
@@ -188,6 +188,10 @@ export class RunOrchestrator {
|
|
|
188
188
|
await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
|
|
189
189
|
await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
|
|
190
190
|
}
|
|
191
|
+
// Freshen the worktree: fetch + rebase onto latest base branch.
|
|
192
|
+
// This prevents branch contamination when local main has drifted
|
|
193
|
+
// and avoids scope-bundling review rejections from stale commits.
|
|
194
|
+
await this.freshenWorktree(worktreePath, project, issue);
|
|
191
195
|
// Run prepare-worktree hook
|
|
192
196
|
const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
|
|
193
197
|
const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
|
|
@@ -246,6 +250,60 @@ export class RunOrchestrator {
|
|
|
246
250
|
void this.emitLinearActivity(freshIssue, buildRunStartedActivity(runType));
|
|
247
251
|
void this.syncLinearSession(freshIssue, { activeRunType: runType });
|
|
248
252
|
}
|
|
253
|
+
// ─── Pre-run branch freshening ────────────────────────────────────
|
|
254
|
+
/**
|
|
255
|
+
* Fetch origin and rebase the worktree onto the latest base branch.
|
|
256
|
+
*
|
|
257
|
+
* Risks mitigated:
|
|
258
|
+
* - Dirty worktree from interrupted run → stash before, pop after
|
|
259
|
+
* - Conflicts → abort rebase, throw so the run fails with a clear reason
|
|
260
|
+
* - Already up-to-date → no-op, no force-push needed
|
|
261
|
+
* - Force-push invalidates reviews → only push if rebase actually moved commits
|
|
262
|
+
*/
|
|
263
|
+
async freshenWorktree(worktreePath, project, issue) {
|
|
264
|
+
const gitBin = this.config.runner.gitBin;
|
|
265
|
+
const baseBranch = project.github?.baseBranch ?? "main";
|
|
266
|
+
// Stash any uncommitted changes from a previous interrupted run
|
|
267
|
+
const stashResult = await execCommand(gitBin, ["-C", worktreePath, "stash"], { timeoutMs: 30_000 });
|
|
268
|
+
const didStash = stashResult.exitCode === 0 && !stashResult.stdout?.includes("No local changes");
|
|
269
|
+
// Fetch latest base
|
|
270
|
+
const fetchResult = await execCommand(gitBin, ["-C", worktreePath, "fetch", "origin", baseBranch], { timeoutMs: 60_000 });
|
|
271
|
+
if (fetchResult.exitCode !== 0) {
|
|
272
|
+
this.logger.warn({ issueKey: issue.issueKey, stderr: fetchResult.stderr?.slice(0, 300) }, "Pre-run fetch failed, proceeding with current base");
|
|
273
|
+
if (didStash)
|
|
274
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Check if rebase is needed: is HEAD already on top of origin/baseBranch?
|
|
278
|
+
const mergeBaseResult = await execCommand(gitBin, ["-C", worktreePath, "merge-base", "--is-ancestor", `origin/${baseBranch}`, "HEAD"], { timeoutMs: 10_000 });
|
|
279
|
+
if (mergeBaseResult.exitCode === 0) {
|
|
280
|
+
// Already up-to-date — no rebase needed
|
|
281
|
+
this.logger.debug({ issueKey: issue.issueKey }, "Pre-run freshen: branch already up to date");
|
|
282
|
+
if (didStash)
|
|
283
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Rebase onto latest base
|
|
287
|
+
const rebaseResult = await execCommand(gitBin, ["-C", worktreePath, "rebase", `origin/${baseBranch}`], { timeoutMs: 120_000 });
|
|
288
|
+
if (rebaseResult.exitCode !== 0) {
|
|
289
|
+
// Abort the failed rebase and restore state
|
|
290
|
+
await execCommand(gitBin, ["-C", worktreePath, "rebase", "--abort"], { timeoutMs: 10_000 });
|
|
291
|
+
if (didStash)
|
|
292
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
293
|
+
throw new Error(`Pre-run rebase onto origin/${baseBranch} failed with conflicts — escalate or resolve manually`);
|
|
294
|
+
}
|
|
295
|
+
// Push the rebased branch (force-with-lease to protect against concurrent pushes)
|
|
296
|
+
const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
|
|
297
|
+
if (pushResult.exitCode !== 0) {
|
|
298
|
+
this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Pre-run rebase push failed, proceeding anyway");
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased and pushed onto latest base");
|
|
302
|
+
}
|
|
303
|
+
// Restore stashed changes
|
|
304
|
+
if (didStash)
|
|
305
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
306
|
+
}
|
|
249
307
|
// ─── Notification handler ─────────────────────────────────────────
|
|
250
308
|
async handleCodexNotification(notification) {
|
|
251
309
|
// threadId is present on turn-level notifications but NOT on item-level ones.
|
|
@@ -492,6 +550,15 @@ export class RunOrchestrator {
|
|
|
492
550
|
...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
493
551
|
...(pendingRunType ? { pendingRunType: pendingRunType } : {}),
|
|
494
552
|
});
|
|
553
|
+
this.feed?.publish({
|
|
554
|
+
level: "info",
|
|
555
|
+
kind: "stage",
|
|
556
|
+
issueKey: issue.issueKey,
|
|
557
|
+
projectId: issue.projectId,
|
|
558
|
+
stage: newState,
|
|
559
|
+
status: "reconciled",
|
|
560
|
+
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
561
|
+
});
|
|
495
562
|
if (newState === "awaiting_queue" || pendingRunType) {
|
|
496
563
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
497
564
|
}
|
|
@@ -555,6 +622,15 @@ export class RunOrchestrator {
|
|
|
555
622
|
factoryState: "done",
|
|
556
623
|
});
|
|
557
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
|
+
});
|
|
558
634
|
return;
|
|
559
635
|
}
|
|
560
636
|
}
|
|
@@ -603,6 +679,17 @@ export class RunOrchestrator {
|
|
|
603
679
|
...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
|
|
604
680
|
});
|
|
605
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
|
+
}
|
|
606
693
|
if (postRunState === "awaiting_queue") {
|
|
607
694
|
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
608
695
|
}
|
|
@@ -756,10 +843,12 @@ export class RunOrchestrator {
|
|
|
756
843
|
*/
|
|
757
844
|
function resolvePostRunState(issue) {
|
|
758
845
|
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
|
|
759
|
-
|
|
760
|
-
|
|
846
|
+
// Check merged first — a merged PR is both approved and merged,
|
|
847
|
+
// and "done" must take priority over "awaiting_queue".
|
|
761
848
|
if (issue.prState === "merged")
|
|
762
849
|
return "done";
|
|
850
|
+
if (issue.prReviewState === "approved")
|
|
851
|
+
return "awaiting_queue";
|
|
763
852
|
return "pr_open";
|
|
764
853
|
}
|
|
765
854
|
return undefined;
|
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
|
}
|