patchrelay 0.52.1 → 0.52.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.52.1",
4
- "commit": "ce226d1e6424",
5
- "builtAt": "2026-04-22T15:51:04.485Z"
3
+ "version": "0.52.3",
4
+ "commit": "76e8fb03067d",
5
+ "builtAt": "2026-04-22T19:20:36.664Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -6,6 +6,7 @@ import { extractCompletionCheck } from "../completion-check.js";
6
6
  import { getThreadTurns } from "../codex-thread-utils.js";
7
7
  import { PatchRelayDatabase } from "../db.js";
8
8
  import { buildManualRetryAttemptReset, resolveRetryTarget } from "../manual-issue-actions.js";
9
+ import { buildOperatorRetryEvent } from "../operator-retry-event.js";
9
10
  import { WorktreeManager } from "../worktree-manager.js";
10
11
  import { parseDelegationObservedPayload, parseRunReleasedAuthorityPayload } from "../delegation-audit.js";
11
12
  import { CliOperatorApiClient } from "./operator-client.js";
@@ -386,61 +387,10 @@ export class CliDataAccess extends CliOperatorApiClient {
386
387
  };
387
388
  }
388
389
  appendRetryWake(issue, runType) {
389
- if (runType === "queue_repair") {
390
- const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
391
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
392
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
393
- projectId: issue.projectId,
394
- linearIssueId: issue.linearIssueId,
395
- eventType: "merge_steward_incident",
396
- eventJson: JSON.stringify({
397
- ...(queueIncident ?? {}),
398
- ...(failureContext ?? {}),
399
- source: "operator_retry",
400
- }),
401
- dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
402
- });
403
- return;
404
- }
405
- if (runType === "ci_repair") {
406
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
407
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
408
- projectId: issue.projectId,
409
- linearIssueId: issue.linearIssueId,
410
- eventType: "settled_red_ci",
411
- eventJson: JSON.stringify({
412
- ...(failureContext ?? {}),
413
- source: "operator_retry",
414
- }),
415
- dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
416
- });
417
- return;
418
- }
419
- if (runType === "review_fix" || runType === "branch_upkeep") {
420
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
421
- projectId: issue.projectId,
422
- linearIssueId: issue.linearIssueId,
423
- eventType: "review_changes_requested",
424
- eventJson: JSON.stringify({
425
- reviewBody: runType === "branch_upkeep"
426
- ? "Operator requested retry of branch upkeep after requested changes."
427
- : "Operator requested retry of review-fix work.",
428
- ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
429
- source: "operator_retry",
430
- }),
431
- dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
432
- });
433
- return;
434
- }
435
390
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
436
391
  projectId: issue.projectId,
437
392
  linearIssueId: issue.linearIssueId,
438
- eventType: "delegated",
439
- eventJson: JSON.stringify({
440
- promptContext: "Operator requested retry of PatchRelay work.",
441
- source: "operator_retry",
442
- }),
443
- dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
393
+ ...buildOperatorRetryEvent(issue, runType),
444
394
  });
445
395
  }
446
396
  list(options) {
@@ -38,7 +38,7 @@ export class ImplementationOutcomePolicy {
38
38
  return await this.describeLocalImplementationOutcome(issue, baseBranch);
39
39
  }
40
40
  async detectPublishedPrState(run, issue, repoFullName) {
41
- if (issue.prNumber && issue.prState && issue.prState !== "closed") {
41
+ if (issue.prNumber && isOpenPrState(issue.prState)) {
42
42
  return "open";
43
43
  }
44
44
  if (!repoFullName || !issue.branchName) {
@@ -61,24 +61,30 @@ export class ImplementationOutcomePolicy {
61
61
  return "unknown";
62
62
  }
63
63
  const matches = JSON.parse(stdout);
64
- const pr = matches[0];
64
+ const pr = matches.find((candidate) => isOpenPrState(candidate.state)) ?? matches[0];
65
65
  if (!pr?.number) {
66
+ this.clearObservedPrIfLeaseHeld(issue, "published PR verification found no PRs for branch");
66
67
  return "none";
67
68
  }
68
69
  const state = pr.state?.toLowerCase();
69
- this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
70
- projectId: issue.projectId,
71
- linearIssueId: issue.linearIssueId,
72
- prNumber: pr.number,
73
- ...(pr.url ? { prUrl: pr.url } : {}),
74
- ...(state ? { prState: state } : {}),
75
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
76
- ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
77
- }, "published PR verification refresh");
78
- if (state !== "closed" && isMainRepairIssue(issue)) {
70
+ if (isOpenPrState(state)) {
71
+ this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
72
+ projectId: issue.projectId,
73
+ linearIssueId: issue.linearIssueId,
74
+ prNumber: pr.number,
75
+ ...(pr.url ? { prUrl: pr.url } : {}),
76
+ ...(state ? { prState: state } : {}),
77
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
78
+ ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
79
+ }, "published PR verification refresh");
80
+ }
81
+ else {
82
+ this.clearObservedPrIfLeaseHeld(issue, "published PR verification found only historical PRs for branch");
83
+ }
84
+ if (isOpenPrState(state) && isMainRepairIssue(issue)) {
79
85
  await this.ensurePriorityQueueLabel(run.projectId, pr.number, repoFullName);
80
86
  }
81
- return state === "closed" ? "closed" : "open";
87
+ return isOpenPrState(state) ? "open" : "closed";
82
88
  }
83
89
  catch (error) {
84
90
  this.logger.debug({
@@ -97,6 +103,20 @@ export class ImplementationOutcomePolicy {
97
103
  }
98
104
  return updated;
99
105
  }
106
+ clearObservedPrIfLeaseHeld(issue, context) {
107
+ this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
108
+ projectId: issue.projectId,
109
+ linearIssueId: issue.linearIssueId,
110
+ prNumber: null,
111
+ prUrl: null,
112
+ prState: null,
113
+ prIsDraft: null,
114
+ prHeadSha: null,
115
+ prAuthorLogin: null,
116
+ prReviewState: null,
117
+ prCheckStatus: null,
118
+ }, context);
119
+ }
100
120
  async describeLocalImplementationOutcome(issue, baseBranch) {
101
121
  if (!issue.worktreePath) {
102
122
  return undefined;
@@ -177,3 +197,9 @@ export class ImplementationOutcomePolicy {
177
197
  }
178
198
  }
179
199
  }
200
+ function isOpenPrState(state) {
201
+ if (!state)
202
+ return false;
203
+ const normalized = state.trim().toLowerCase();
204
+ return normalized !== "closed" && normalized !== "merged";
205
+ }
@@ -170,6 +170,51 @@ export async function handleNoPrCompletionCheck(params) {
170
170
  });
171
171
  return;
172
172
  }
173
+ if (params.run.runType === "main_repair") {
174
+ const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
175
+ params.db.runs.finishRun(params.run.id, runUpdate);
176
+ params.db.runs.saveCompletionCheck(params.run.id, {
177
+ ...completionCheck,
178
+ outcome: "continue",
179
+ summary: "Main repair cannot finish without a published repair PR; continuing automatically until the fix is published or main recovers externally.",
180
+ why: completionCheck.summary,
181
+ });
182
+ params.db.issues.upsertIssue({
183
+ projectId: params.run.projectId,
184
+ linearIssueId: params.run.linearIssueId,
185
+ activeRunId: null,
186
+ factoryState: "delegated",
187
+ pendingRunType: null,
188
+ pendingRunContextJson: null,
189
+ });
190
+ return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
191
+ projectId: params.run.projectId,
192
+ linearIssueId: params.run.linearIssueId,
193
+ eventType: "completion_check_continue",
194
+ eventJson: JSON.stringify({
195
+ runType: params.run.runType,
196
+ summary: params.publishedOutcomeError,
197
+ }),
198
+ dedupeKey: `completion_check_continue:${params.run.id}`,
199
+ }));
200
+ });
201
+ if (!continued) {
202
+ params.logger.warn({ runId: params.run.id, issueId: params.run.linearIssueId }, "Skipping main-repair completion-check continue writes after losing issue-session lease");
203
+ params.clearProgressAndRelease(params.run);
204
+ return;
205
+ }
206
+ params.syncCompletionCheckOutcome({
207
+ run: params.run,
208
+ fallbackIssue: params.issue,
209
+ level: "info",
210
+ status: "completion_check_continue",
211
+ summary: "No repair PR found; continuing automatically",
212
+ detail: "Main repair cannot close until PatchRelay publishes a repair PR or main recovers externally.",
213
+ activity: buildCompletionCheckActivity("continue"),
214
+ enqueue: true,
215
+ });
216
+ return;
217
+ }
173
218
  const orchestrationOpenChildren = params.issue.issueClass === "orchestration"
174
219
  ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
175
220
  : 0;
@@ -38,9 +38,9 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
38
38
  return {
39
39
  eventType: "review_changes_requested",
40
40
  eventJson: JSON.stringify({
41
- reviewBody: runType === "branch_upkeep"
42
- ? `${humanizeSource(source)} requested retry of branch upkeep after requested changes.`
43
- : `${humanizeSource(source)} requested retry of review-fix work.`,
41
+ ...(runType === "branch_upkeep"
42
+ ? { reviewBody: `${humanizeSource(source)} requested retry of branch upkeep after requested changes.` }
43
+ : { promptContext: `${humanizeSource(source)} requested retry of review-fix work.` }),
44
44
  ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
45
45
  source,
46
46
  }),
@@ -1,4 +1,5 @@
1
1
  import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
2
+ import { readLatestRequestedChangesReviewContext } from "./remote-pr-review.js";
2
3
  export class ReactiveRunPolicy {
3
4
  config;
4
5
  db;
@@ -136,6 +137,7 @@ export class ReactiveRunPolicy {
136
137
  const snapshot = await readReactivePrSnapshot(this.config, issue.projectId, issue.prNumber);
137
138
  if (!snapshot)
138
139
  return context;
140
+ const refreshedContext = await this.hydrateRequestedChangesContext(issue.projectId, issue.prNumber, snapshot.repoFullName, snapshot.headSha, context);
139
141
  this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
140
142
  projectId: issue.projectId,
141
143
  linearIssueId: issue.linearIssueId,
@@ -144,12 +146,12 @@ export class ReactiveRunPolicy {
144
146
  ...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
145
147
  }, "review-fix wake refresh");
146
148
  if (snapshot.prState !== "open")
147
- return context;
149
+ return refreshedContext;
148
150
  if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
149
- return context;
151
+ return refreshedContext;
150
152
  if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
151
- return context;
152
- return buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr, context);
153
+ return refreshedContext;
154
+ return buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr, refreshedContext);
153
155
  }
154
156
  catch (error) {
155
157
  this.logger.debug({
@@ -210,4 +212,44 @@ export class ReactiveRunPolicy {
210
212
  }
211
213
  return updated;
212
214
  }
215
+ async hydrateRequestedChangesContext(projectId, prNumber, repoFullName, headSha, context) {
216
+ const merged = {
217
+ ...(context ?? {}),
218
+ ...(headSha ? { headSha } : {}),
219
+ };
220
+ if (hasStructuredReviewContext(merged)) {
221
+ return merged;
222
+ }
223
+ const liveReview = await readLatestRequestedChangesReviewContext(repoFullName, prNumber);
224
+ if (!liveReview) {
225
+ return Object.keys(merged).length > 0 ? merged : context;
226
+ }
227
+ return {
228
+ ...merged,
229
+ ...(liveReview.reviewId !== undefined ? { reviewId: liveReview.reviewId } : {}),
230
+ ...(liveReview.reviewCommitId ? { reviewCommitId: liveReview.reviewCommitId } : {}),
231
+ ...(liveReview.reviewUrl ? { reviewUrl: liveReview.reviewUrl } : {}),
232
+ ...(liveReview.reviewerName ? { reviewerName: liveReview.reviewerName } : {}),
233
+ ...(liveReview.reviewBody ? { reviewBody: liveReview.reviewBody } : {}),
234
+ ...(liveReview.reviewComments ? { reviewComments: liveReview.reviewComments } : {}),
235
+ };
236
+ }
237
+ }
238
+ function hasStructuredReviewContext(context) {
239
+ if (!context)
240
+ return false;
241
+ const reviewBody = typeof context.reviewBody === "string" ? context.reviewBody.trim() : "";
242
+ const isOperatorRetryPlaceholder = typeof context.source === "string"
243
+ && context.source === "operator_retry"
244
+ && /^operator requested retry of review-fix work\.?$/i.test(reviewBody);
245
+ if (isOperatorRetryPlaceholder) {
246
+ return false;
247
+ }
248
+ if (reviewBody)
249
+ return true;
250
+ if (typeof context.reviewUrl === "string" && context.reviewUrl.trim())
251
+ return true;
252
+ if (typeof context.reviewerName === "string" && context.reviewerName.trim())
253
+ return true;
254
+ return Array.isArray(context.reviewComments) && context.reviewComments.length > 0;
213
255
  }
@@ -0,0 +1,60 @@
1
+ import { execCommand, safeJsonParse } from "./utils.js";
2
+ export async function readLatestRequestedChangesReviewContext(repoFullName, prNumber) {
3
+ const [owner, repo] = repoFullName.split("/", 2);
4
+ if (!owner || !repo) {
5
+ return undefined;
6
+ }
7
+ const reviewsResult = await execCommand("gh", [
8
+ "api",
9
+ `repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100`,
10
+ ], { timeoutMs: 10_000 });
11
+ if (reviewsResult.exitCode !== 0) {
12
+ return undefined;
13
+ }
14
+ const reviews = safeJsonParse(reviewsResult.stdout);
15
+ if (!Array.isArray(reviews)) {
16
+ return undefined;
17
+ }
18
+ const review = [...reviews].reverse().find((entry) => entry?.state?.trim().toUpperCase() === "CHANGES_REQUESTED");
19
+ if (!review?.id) {
20
+ return undefined;
21
+ }
22
+ const comments = await readReviewComments(owner, repo, prNumber, review.id);
23
+ return {
24
+ reviewId: review.id,
25
+ ...(typeof review.commit_id === "string" && review.commit_id.trim() ? { reviewCommitId: review.commit_id.trim() } : {}),
26
+ ...(typeof review.html_url === "string" && review.html_url.trim() ? { reviewUrl: review.html_url.trim() } : {}),
27
+ ...(typeof review.body === "string" && review.body.trim() ? { reviewBody: review.body.trim() } : {}),
28
+ ...(typeof review.user?.login === "string" && review.user.login.trim() ? { reviewerName: review.user.login.trim() } : {}),
29
+ ...(comments.length > 0 ? { reviewComments: comments } : {}),
30
+ };
31
+ }
32
+ async function readReviewComments(owner, repo, prNumber, reviewId) {
33
+ const commentsResult = await execCommand("gh", [
34
+ "api",
35
+ `repos/${owner}/${repo}/pulls/${prNumber}/reviews/${reviewId}/comments?per_page=100`,
36
+ ], { timeoutMs: 10_000 });
37
+ if (commentsResult.exitCode !== 0) {
38
+ return [];
39
+ }
40
+ const comments = safeJsonParse(commentsResult.stdout);
41
+ if (!Array.isArray(comments)) {
42
+ return [];
43
+ }
44
+ return comments.flatMap((entry) => {
45
+ const body = typeof entry.body === "string" ? entry.body.trim() : "";
46
+ if (!body) {
47
+ return [];
48
+ }
49
+ return [{
50
+ body,
51
+ ...(typeof entry.path === "string" ? { path: entry.path } : {}),
52
+ ...(typeof entry.line === "number" ? { line: entry.line } : {}),
53
+ ...(typeof entry.side === "string" ? { side: entry.side } : {}),
54
+ ...(typeof entry.start_line === "number" ? { startLine: entry.start_line } : {}),
55
+ ...(typeof entry.start_side === "string" ? { startSide: entry.start_side } : {}),
56
+ ...(typeof entry.html_url === "string" ? { url: entry.html_url } : {}),
57
+ ...(typeof entry.user?.login === "string" ? { authorLogin: entry.user.login } : {}),
58
+ }];
59
+ });
60
+ }
@@ -18,6 +18,16 @@ function shouldCompactThread(issue, threadGeneration, context) {
18
18
  export function shouldReuseIssueThread(params) {
19
19
  return Boolean(params.existingThreadId) && !params.compactThread && params.resumeThread;
20
20
  }
21
+ export function shouldFreshenWorktreeBeforeLaunch(params) {
22
+ if (params.runType === "queue_repair") {
23
+ return false;
24
+ }
25
+ if (params.runType === "review_fix") {
26
+ return params.effectiveContext?.branchUpkeepRequired === true
27
+ || params.effectiveContext?.reviewFixMode === "branch_upkeep";
28
+ }
29
+ return true;
30
+ }
21
31
  export class RunLauncher {
22
32
  config;
23
33
  db;
@@ -125,7 +135,10 @@ export class RunLauncher {
125
135
  });
126
136
  }
127
137
  await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
128
- if (params.runType !== "queue_repair") {
138
+ if (shouldFreshenWorktreeBeforeLaunch({
139
+ runType: params.runType,
140
+ ...(params.effectiveContext ? { effectiveContext: params.effectiveContext } : {}),
141
+ })) {
129
142
  await this.worktreeManager.freshenWorktree(params.worktreePath, params.project, params.issue, this.logger);
130
143
  }
131
144
  const hookEnv = buildHookEnv(params.issue.issueKey ?? params.issue.linearIssueId, params.branchName, params.runType, params.worktreePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.52.1",
3
+ "version": "0.52.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {