patchrelay 0.23.1 → 0.23.3

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.1",
4
- "commit": "b04a5fd406df",
5
- "builtAt": "2026-03-26T12:04:01.904Z"
3
+ "version": "0.23.3",
4
+ "commit": "1c8c52702f63",
5
+ "builtAt": "2026-03-26T13:46:43.445Z"
6
6
  }
@@ -19,14 +19,14 @@ const STATE_SHORT = {
19
19
  preparing: "prep",
20
20
  implementing: "impl",
21
21
  pr_open: "pr open",
22
- changes_requested: "changes",
22
+ changes_requested: "review fix",
23
23
  repairing_ci: "ci fix",
24
24
  awaiting_queue: "merging",
25
25
  repairing_queue: "merge fix",
26
26
  done: "done",
27
27
  failed: "failed",
28
28
  escalated: "escalated",
29
- awaiting_input: "input",
29
+ awaiting_input: "paused",
30
30
  };
31
31
  const RUN_SHORT = {
32
32
  implementation: "impl",
@@ -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`), address each point, run verification, commit and push.", "");
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
  }
@@ -611,11 +678,15 @@ export class RunOrchestrator {
611
678
  // ─── Internal helpers ─────────────────────────────────────────────
612
679
  escalate(issue, runType, reason) {
613
680
  this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
681
+ if (issue.activeRunId) {
682
+ this.db.finishRun(issue.activeRunId, { status: "released" });
683
+ }
614
684
  this.db.upsertIssue({
615
685
  projectId: issue.projectId,
616
686
  linearIssueId: issue.linearIssueId,
617
687
  pendingRunType: null,
618
688
  pendingRunContextJson: null,
689
+ activeRunId: null,
619
690
  factoryState: "escalated",
620
691
  });
621
692
  this.feed?.publish({
@@ -752,10 +823,12 @@ export class RunOrchestrator {
752
823
  */
753
824
  function resolvePostRunState(issue) {
754
825
  if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
755
- if (issue.prReviewState === "approved")
756
- return "awaiting_queue";
826
+ // Check merged first — a merged PR is both approved and merged,
827
+ // and "done" must take priority over "awaiting_queue".
757
828
  if (issue.prState === "merged")
758
829
  return "done";
830
+ if (issue.prReviewState === "approved")
831
+ return "awaiting_queue";
759
832
  return "pr_open";
760
833
  }
761
834
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {