patchrelay 0.50.3 → 0.50.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.50.3",
4
- "commit": "19ecf3f0c209",
5
- "builtAt": "2026-04-20T19:09:17.975Z"
3
+ "version": "0.50.4",
4
+ "commit": "0063b47a1df7",
5
+ "builtAt": "2026-04-21T12:01:24.460Z"
6
6
  }
@@ -16,53 +16,74 @@ export class ImplementationOutcomePolicy {
16
16
  }
17
17
  const project = this.config.projects.find((entry) => entry.id === run.projectId);
18
18
  const baseBranch = project?.github?.baseBranch ?? "main";
19
- if (issue.prNumber && issue.prState && issue.prState !== "closed") {
19
+ const publishedPrState = await this.detectPublishedPrState(issue, project?.github?.repoFullName);
20
+ if (publishedPrState === "open") {
20
21
  return undefined;
21
22
  }
22
- if (project?.github?.repoFullName && issue.branchName) {
23
- try {
24
- const { stdout, exitCode } = await execCommand("gh", [
25
- "pr",
26
- "list",
27
- "--repo",
28
- project.github.repoFullName,
29
- "--head",
30
- issue.branchName,
31
- "--state",
32
- "all",
33
- "--json",
34
- "number,url,state,author,headRefOid",
35
- ], { timeoutMs: 10_000 });
36
- if (exitCode === 0) {
37
- const matches = JSON.parse(stdout);
38
- const pr = matches[0];
39
- if (pr?.number) {
40
- this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
41
- projectId: issue.projectId,
42
- linearIssueId: issue.linearIssueId,
43
- prNumber: pr.number,
44
- ...(pr.url ? { prUrl: pr.url } : {}),
45
- ...(pr.state ? { prState: pr.state.toLowerCase() } : {}),
46
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
47
- ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
48
- }, "published PR verification refresh");
49
- if (pr.state?.toLowerCase() !== "closed") {
50
- return undefined;
51
- }
52
- }
53
- }
23
+ const details = await this.describeLocalImplementationOutcome(issue, baseBranch);
24
+ return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
25
+ }
26
+ async detectRecoverableFailedImplementationOutcome(run, issue) {
27
+ if (run.runType !== "implementation") {
28
+ return undefined;
29
+ }
30
+ const project = this.config.projects.find((entry) => entry.id === run.projectId);
31
+ const publishedPrState = await this.detectPublishedPrState(issue, project?.github?.repoFullName);
32
+ if (publishedPrState === "open" || publishedPrState === "unknown") {
33
+ return undefined;
34
+ }
35
+ const baseBranch = project?.github?.baseBranch ?? "main";
36
+ return await this.describeLocalImplementationOutcome(issue, baseBranch);
37
+ }
38
+ async detectPublishedPrState(issue, repoFullName) {
39
+ if (issue.prNumber && issue.prState && issue.prState !== "closed") {
40
+ return "open";
41
+ }
42
+ if (!repoFullName || !issue.branchName) {
43
+ return "unknown";
44
+ }
45
+ try {
46
+ const { stdout, exitCode } = await execCommand("gh", [
47
+ "pr",
48
+ "list",
49
+ "--repo",
50
+ repoFullName,
51
+ "--head",
52
+ issue.branchName,
53
+ "--state",
54
+ "all",
55
+ "--json",
56
+ "number,url,state,author,headRefOid",
57
+ ], { timeoutMs: 10_000 });
58
+ if (exitCode !== 0) {
59
+ return "unknown";
54
60
  }
55
- catch (error) {
56
- this.logger.debug({
57
- issueKey: issue.issueKey,
58
- branchName: issue.branchName,
59
- repoFullName: project.github.repoFullName,
60
- error: error instanceof Error ? error.message : String(error),
61
- }, "Failed to verify published PR state after implementation");
61
+ const matches = JSON.parse(stdout);
62
+ const pr = matches[0];
63
+ if (!pr?.number) {
64
+ return "none";
62
65
  }
66
+ const state = pr.state?.toLowerCase();
67
+ this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
68
+ projectId: issue.projectId,
69
+ linearIssueId: issue.linearIssueId,
70
+ prNumber: pr.number,
71
+ ...(pr.url ? { prUrl: pr.url } : {}),
72
+ ...(state ? { prState: state } : {}),
73
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
74
+ ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
75
+ }, "published PR verification refresh");
76
+ return state === "closed" ? "closed" : "open";
77
+ }
78
+ catch (error) {
79
+ this.logger.debug({
80
+ issueKey: issue.issueKey,
81
+ branchName: issue.branchName,
82
+ repoFullName,
83
+ error: error instanceof Error ? error.message : String(error),
84
+ }, "Failed to verify published PR state after implementation");
85
+ return "unknown";
63
86
  }
64
- const details = await this.describeLocalImplementationOutcome(issue, baseBranch);
65
- return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
66
87
  }
67
88
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
68
89
  const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
@@ -8,10 +8,12 @@ function shouldContinueForUnpublishedLocalChanges(message) {
8
8
  || (normalized.includes("local commit") && normalized.includes("ahead of origin/"));
9
9
  }
10
10
  export async function handleNoPrCompletionCheck(params) {
11
- const completedRunUpdate = buildCompletedRunUpdate({
11
+ const runUpdate = buildRunUpdate({
12
+ status: params.runStatus,
12
13
  threadId: params.threadId,
13
14
  ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
14
15
  report: params.report,
16
+ ...(params.failureReason ? { failureReason: params.failureReason } : {}),
15
17
  });
16
18
  params.publishTurnEvent({
17
19
  level: "info",
@@ -53,7 +55,7 @@ export async function handleNoPrCompletionCheck(params) {
53
55
  }
54
56
  if (completionCheck.outcome === "continue") {
55
57
  const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
56
- params.db.runs.finishRun(params.run.id, completedRunUpdate);
58
+ params.db.runs.finishRun(params.run.id, runUpdate);
57
59
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
58
60
  params.db.issues.upsertIssue({
59
61
  projectId: params.run.projectId,
@@ -93,7 +95,7 @@ export async function handleNoPrCompletionCheck(params) {
93
95
  }
94
96
  if (completionCheck.outcome === "needs_input") {
95
97
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
96
- params.db.runs.finishRun(params.run.id, completedRunUpdate);
98
+ params.db.runs.finishRun(params.run.id, runUpdate);
97
99
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
98
100
  params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
99
101
  params.db.issues.upsertIssue({
@@ -125,7 +127,7 @@ export async function handleNoPrCompletionCheck(params) {
125
127
  if (completionCheck.outcome === "done") {
126
128
  if (shouldContinueForUnpublishedLocalChanges(params.publishedOutcomeError)) {
127
129
  const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
128
- params.db.runs.finishRun(params.run.id, completedRunUpdate);
130
+ params.db.runs.finishRun(params.run.id, runUpdate);
129
131
  params.db.runs.saveCompletionCheck(params.run.id, {
130
132
  ...completionCheck,
131
133
  outcome: "continue",
@@ -172,7 +174,7 @@ export async function handleNoPrCompletionCheck(params) {
172
174
  ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
173
175
  : 0;
174
176
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
175
- params.db.runs.finishRun(params.run.id, completedRunUpdate);
177
+ params.db.runs.finishRun(params.run.id, runUpdate);
176
178
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
177
179
  params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
178
180
  params.db.issues.upsertIssue({
@@ -226,7 +228,7 @@ export async function handleNoPrCompletionCheck(params) {
226
228
  const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
227
229
  const failed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, () => {
228
230
  params.db.runs.finishRun(params.run.id, {
229
- ...completedRunUpdate,
231
+ ...runUpdate,
230
232
  status: "failed",
231
233
  failureReason,
232
234
  });
@@ -256,12 +258,13 @@ export async function handleNoPrCompletionCheck(params) {
256
258
  detail: completionCheck.summary,
257
259
  });
258
260
  }
259
- function buildCompletedRunUpdate(params) {
261
+ function buildRunUpdate(params) {
260
262
  return {
261
- status: "completed",
263
+ status: params.status,
262
264
  threadId: params.threadId,
263
265
  ...(params.completedTurnId ? { turnId: params.completedTurnId } : {}),
264
266
  summaryJson: JSON.stringify({ latestAssistantMessage: params.report.assistantMessages.at(-1) ?? null }),
265
267
  reportJson: JSON.stringify(params.report),
268
+ ...(params.failureReason ? { failureReason: params.failureReason } : {}),
266
269
  };
267
270
  }
@@ -39,4 +39,7 @@ export class RunCompletionPolicy {
39
39
  async verifyPublishedRunOutcome(run, issue) {
40
40
  return await this.implementationOutcomes.verifyPublishedRunOutcome(run, issue);
41
41
  }
42
+ async detectRecoverableFailedImplementationOutcome(run, issue) {
43
+ return await this.implementationOutcomes.detectRecoverableFailedImplementationOutcome(run, issue);
44
+ }
42
45
  }
@@ -126,6 +126,7 @@ export class RunFinalizer {
126
126
  run,
127
127
  issue: freshIssue,
128
128
  report,
129
+ runStatus: "completed",
129
130
  threadId,
130
131
  ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
131
132
  publishedOutcomeError,
@@ -220,4 +221,34 @@ export class RunFinalizer {
220
221
  this.linearSync.clearProgress(run.id);
221
222
  this.releaseLease(run.projectId, run.linearIssueId);
222
223
  }
224
+ async recoverFailedImplementationRun(params) {
225
+ const freshIssue = this.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;
226
+ const publishedOutcomeError = await this.completionPolicy.detectRecoverableFailedImplementationOutcome(params.run, freshIssue);
227
+ if (!publishedOutcomeError) {
228
+ return false;
229
+ }
230
+ const trackedIssue = this.db.issueToTrackedIssue(freshIssue);
231
+ const report = buildStageReport({ ...params.run, status: "failed" }, trackedIssue, params.thread, countEventMethods(this.db.runs.listThreadEvents(params.run.id)));
232
+ await handleNoPrCompletionCheck({
233
+ db: this.db,
234
+ logger: this.logger,
235
+ withHeldLease: this.withHeldLease,
236
+ completionCheck: this.completionCheck,
237
+ run: params.run,
238
+ issue: freshIssue,
239
+ report,
240
+ runStatus: "failed",
241
+ threadId: params.threadId,
242
+ ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
243
+ failureReason: params.failureReason,
244
+ publishedOutcomeError,
245
+ failRunAndClear: this.failRunAndClear,
246
+ emitActivity: (issueRecord, activity, options) => this.linearSync.emitActivity(issueRecord, activity, options),
247
+ publishTurnEvent: (event) => this.publishTurnEvent(event),
248
+ syncFailureOutcome: (event) => this.syncFailureOutcome(event),
249
+ syncCompletionCheckOutcome: (event) => this.syncCompletionCheckOutcome(event),
250
+ clearProgressAndRelease: (releaseRun) => this.clearProgressAndRelease(releaseRun),
251
+ });
252
+ return true;
253
+ }
223
254
  }
@@ -75,13 +75,26 @@ export class RunNotificationHandler {
75
75
  const completedTurnId = extractTurnId(notification.params);
76
76
  const status = resolveRunCompletionStatus(notification.params);
77
77
  if (status === "failed") {
78
+ const failureReason = "Codex reported the turn completed in a failed state";
79
+ const recovered = await this.runFinalizer.recoverFailedImplementationRun({
80
+ run,
81
+ issue,
82
+ thread,
83
+ threadId,
84
+ ...(completedTurnId ? { completedTurnId } : {}),
85
+ failureReason,
86
+ });
87
+ if (recovered) {
88
+ this.activeThreadId = undefined;
89
+ return;
90
+ }
78
91
  const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
79
92
  const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
80
93
  this.db.issueSessions.finishRunWithLease(lease, run.id, {
81
94
  status: "failed",
82
95
  threadId,
83
96
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
84
- failureReason: "Codex reported the turn completed in a failed state",
97
+ failureReason,
85
98
  });
86
99
  this.db.issueSessions.upsertIssueWithLease(lease, {
87
100
  projectId: run.projectId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.50.3",
3
+ "version": "0.50.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {