patchrelay 0.17.0 → 0.17.1

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.
@@ -93,7 +93,6 @@ export function buildAgentSessionPlan(params) {
93
93
  case "implementing":
94
94
  return setStatuses(planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
95
95
  case "pr_open":
96
- case "awaiting_review":
97
96
  return setStatuses(implementationPlan(), ["completed", "completed", "inProgress", "pending"]);
98
97
  case "changes_requested":
99
98
  return setStatuses(reviewFixPlan(), ["completed", "inProgress", "pending", "pending"]);
@@ -164,7 +163,7 @@ export function buildCompletedSessionPlan(runType) {
164
163
  if (runType === "ci_repair" || runType === "queue_repair") {
165
164
  return buildAgentSessionPlan({ factoryState: "awaiting_queue" });
166
165
  }
167
- return buildAgentSessionPlan({ factoryState: "awaiting_review" });
166
+ return buildAgentSessionPlan({ factoryState: "pr_open" });
168
167
  }
169
168
  export function buildAwaitingHandoffSessionPlan(runType) {
170
169
  return buildCompletedSessionPlan(runType);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.17.0",
4
- "commit": "972d51f6e0da",
5
- "builtAt": "2026-03-25T12:55:22.635Z"
3
+ "version": "0.17.1",
4
+ "commit": "ff6a8d2fcbef",
5
+ "builtAt": "2026-03-25T14:45:25.511Z"
6
6
  }
@@ -5,7 +5,6 @@ const STATE_COLORS = {
5
5
  preparing: "blue",
6
6
  implementing: "yellow",
7
7
  pr_open: "cyan",
8
- awaiting_review: "cyan",
9
8
  changes_requested: "magenta",
10
9
  repairing_ci: "magenta",
11
10
  awaiting_queue: "green",
@@ -133,6 +133,10 @@ export function runPatchRelayMigrations(connection) {
133
133
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
134
134
  // Add merge_prep_attempts for retry budget / escalation
135
135
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
136
+ // Add review_fix_attempts counter
137
+ addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
138
+ // Collapse awaiting_review into pr_open (state normalization)
139
+ connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
136
140
  }
137
141
  function addColumnIfMissing(connection, table, column, definition) {
138
142
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -149,6 +149,10 @@ export class PatchRelayDatabase {
149
149
  sets.push("queue_repair_attempts = @queueRepairAttempts");
150
150
  values.queueRepairAttempts = params.queueRepairAttempts;
151
151
  }
152
+ if (params.reviewFixAttempts !== undefined) {
153
+ sets.push("review_fix_attempts = @reviewFixAttempts");
154
+ values.reviewFixAttempts = params.reviewFixAttempts;
155
+ }
152
156
  if (params.mergePrepAttempts !== undefined) {
153
157
  sets.push("merge_prep_attempts = @mergePrepAttempts");
154
158
  values.mergePrepAttempts = params.mergePrepAttempts;
@@ -389,6 +393,7 @@ function mapIssueRow(row) {
389
393
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
390
394
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
391
395
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
396
+ reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
392
397
  mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
393
398
  pendingMergePrep: Boolean(row.pending_merge_prep),
394
399
  };
@@ -5,10 +5,12 @@ export const ACTIVE_RUN_STATES = new Set([
5
5
  "changes_requested",
6
6
  "repairing_queue",
7
7
  ]);
8
- /** Which factory states are terminal (no further transitions possible). */
8
+ /** Which factory states are terminal (no further transitions possible except pr_merged → done). */
9
9
  export const TERMINAL_STATES = new Set([
10
10
  "done",
11
11
  "escalated",
12
+ "failed",
13
+ "awaiting_input",
12
14
  ]);
13
15
  // ─── Semantic guards ─────────────────────────────────────────────
14
16
  //
@@ -158,8 +158,11 @@ export class GitHubWebhookHandler {
158
158
  }
159
159
  }
160
160
  }
161
- // Reset repair counters on new push
162
- if (event.triggerEvent === "pr_synchronize") {
161
+ // Re-read issue after all upserts so reactive run logic sees current state
162
+ const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
163
+ // Reset repair counters on new push — but only when no repair run is active,
164
+ // since Codex pushes during repair and resetting mid-run would bypass budgets.
165
+ if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
163
166
  this.db.upsertIssue({
164
167
  projectId: issue.projectId,
165
168
  linearIssueId: issue.linearIssueId,
@@ -167,8 +170,6 @@ export class GitHubWebhookHandler {
167
170
  queueRepairAttempts: 0,
168
171
  });
169
172
  }
170
- // Re-read issue after all upserts so reactive run logic sees current state
171
- const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
172
173
  this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
173
174
  this.feed?.publish({
174
175
  level: event.triggerEvent.includes("failed") ? "warn" : "info",
@@ -13,7 +13,6 @@ function describeNextState(state, prNumber) {
13
13
  const prLabel = prNumber ? `PR #${prNumber}` : "the pull request";
14
14
  switch (state) {
15
15
  case "pr_open":
16
- case "awaiting_review":
17
16
  return `${prLabel} is ready for review.`;
18
17
  case "awaiting_queue":
19
18
  return `${prLabel} is approved and back in the merge flow.`;
@@ -149,7 +148,6 @@ export function buildMergePrepEscalationActivity(attempts) {
149
148
  }
150
149
  export function summarizeIssueStateForLinear(issue) {
151
150
  switch (issue.factoryState) {
152
- case "awaiting_review":
153
151
  case "pr_open":
154
152
  return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
155
153
  case "awaiting_queue":
@@ -103,7 +103,6 @@ export class MergeQueue {
103
103
  pendingRunType: "queue_repair",
104
104
  pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
105
105
  pendingMergePrep: false,
106
- mergePrepAttempts: 0,
107
106
  });
108
107
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
109
108
  this.feed?.publish({
@@ -9,8 +9,9 @@ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
9
9
  import { WorktreeManager } from "./worktree-manager.js";
10
10
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
11
11
  import { execCommand } from "./utils.js";
12
- const DEFAULT_CI_REPAIR_BUDGET = 2;
13
- const DEFAULT_QUEUE_REPAIR_BUDGET = 2;
12
+ const DEFAULT_CI_REPAIR_BUDGET = 3;
13
+ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
14
+ const DEFAULT_REVIEW_FIX_BUDGET = 3;
14
15
  function slugify(value) {
15
16
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
16
17
  }
@@ -114,6 +115,10 @@ export class RunOrchestrator {
114
115
  this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
115
116
  return;
116
117
  }
118
+ if (runType === "review_fix" && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
119
+ this.escalate(issue, runType, `Review fix budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
120
+ return;
121
+ }
117
122
  // Increment repair counters
118
123
  if (runType === "ci_repair") {
119
124
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: issue.ciRepairAttempts + 1 });
@@ -121,6 +126,9 @@ export class RunOrchestrator {
121
126
  if (runType === "queue_repair") {
122
127
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: issue.queueRepairAttempts + 1 });
123
128
  }
129
+ if (runType === "review_fix") {
130
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts + 1 });
131
+ }
124
132
  // Build prompt
125
133
  const prompt = buildRunPrompt(issue, runType, project.repoPath, context);
126
134
  // Resolve workspace
@@ -296,22 +304,9 @@ export class RunOrchestrator {
296
304
  // Complete the run
297
305
  const trackedIssue = this.db.issueToTrackedIssue(issue);
298
306
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
299
- // Determine post-run state. When a re-run finds the PR already exists
300
- // and makes no changes, no pr_opened webhook arrives — the state would
301
- // stay in the active-run state forever. Advance based on PR metadata.
307
+ // Determine post-run state based on current PR metadata.
302
308
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
303
- let postRunState;
304
- if (ACTIVE_RUN_STATES.has(freshIssue.factoryState) && freshIssue.prNumber) {
305
- if (freshIssue.prReviewState === "approved") {
306
- postRunState = "awaiting_queue";
307
- }
308
- else if (freshIssue.prState === "merged") {
309
- postRunState = "done";
310
- }
311
- else {
312
- postRunState = "awaiting_review";
313
- }
314
- }
309
+ const postRunState = resolvePostRunState(freshIssue);
315
310
  this.db.transaction(() => {
316
311
  this.db.finishRun(run.id, {
317
312
  status: "completed",
@@ -544,6 +539,8 @@ export class RunOrchestrator {
544
539
  if (latestTurn?.status === "completed") {
545
540
  const trackedIssue = this.db.issueToTrackedIssue(issue);
546
541
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
542
+ const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
543
+ const postRunState = resolvePostRunState(freshIssue);
547
544
  this.db.transaction(() => {
548
545
  this.db.finishRun(run.id, {
549
546
  status: "completed",
@@ -556,8 +553,13 @@ export class RunOrchestrator {
556
553
  projectId: run.projectId,
557
554
  linearIssueId: run.linearIssueId,
558
555
  activeRunId: null,
556
+ ...(postRunState ? { factoryState: postRunState } : {}),
557
+ ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
559
558
  });
560
559
  });
560
+ if (postRunState === "awaiting_queue") {
561
+ this.enqueueIssue(run.projectId, run.linearIssueId);
562
+ }
561
563
  }
562
564
  }
563
565
  // ─── Internal helpers ─────────────────────────────────────────────
@@ -660,3 +662,17 @@ export class RunOrchestrator {
660
662
  throw new Error(`Failed to read thread ${threadId}`);
661
663
  }
662
664
  }
665
+ /**
666
+ * Determine post-run factory state from current PR metadata.
667
+ * Used by both the normal completion path and reconciliation.
668
+ */
669
+ function resolvePostRunState(issue) {
670
+ if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
671
+ if (issue.prReviewState === "approved")
672
+ return "awaiting_queue";
673
+ if (issue.prState === "merged")
674
+ return "done";
675
+ return "pr_open";
676
+ }
677
+ return undefined;
678
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {