patchrelay 0.50.2 → 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.2",
4
- "commit": "59ab92391401",
5
- "builtAt": "2026-04-20T18:42:11.703Z"
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
  }
@@ -157,7 +157,10 @@ function buildOrchestrationConstraints(context) {
157
157
  "## Constraints",
158
158
  "",
159
159
  "This issue is orchestration work. Coordinate convergence instead of duplicating child implementation.",
160
+ "Inspect the current child set before acting. Reuse existing child issues when they already cover the needed slices instead of creating duplicates.",
161
+ "Babysit child progress and solve parent-owned integration or convergence issues when the delivered pieces do not yet fit together cleanly.",
160
162
  "Do not open an overlapping umbrella PR unless this parent owns unique direct work.",
163
+ "Create new child issues only for genuinely missing required work needed to satisfy the parent goal.",
161
164
  "Leave later-wave child issues queued unless they are immediately actionable.",
162
165
  "",
163
166
  "### Child Issue Summaries",
@@ -435,6 +438,8 @@ function buildOrchestrationWorkflowGuidance() {
435
438
  "## Workflow",
436
439
  "",
437
440
  "Use the wake reason and child issue summaries to decide the next orchestration step.",
441
+ "Prefer supervising, auditing, and unblocking existing child work over creating more issues.",
442
+ "If the parent goal now depends on an integration fix between delivered child slices, own that convergence work here without restating already-owned child implementation.",
438
443
  "Keep outputs concise and observable in Linear.",
439
444
  ].join("\n");
440
445
  }
@@ -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.2",
3
+ "version": "0.50.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {