patchrelay 0.68.4 → 0.68.5

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.68.4",
4
- "commit": "7680b548a651",
5
- "builtAt": "2026-05-15T11:58:17.825Z"
3
+ "version": "0.68.5",
4
+ "commit": "cafd8f502dee",
5
+ "builtAt": "2026-05-16T20:40:41.007Z"
6
6
  }
@@ -286,12 +286,22 @@ export class CodexAppServerClient extends EventEmitter {
286
286
  },
287
287
  });
288
288
  });
289
- this.writeMessage({
290
- jsonrpc: "2.0",
291
- id,
292
- method,
293
- params,
294
- });
289
+ try {
290
+ this.writeMessage({
291
+ jsonrpc: "2.0",
292
+ id,
293
+ method,
294
+ params,
295
+ });
296
+ }
297
+ catch (error) {
298
+ const err = error instanceof Error ? error : new Error(String(error));
299
+ const pending = this.pending.get(id);
300
+ if (pending) {
301
+ this.pending.delete(id);
302
+ pending.reject(err);
303
+ }
304
+ }
295
305
  return promise.catch((error) => {
296
306
  const err = error instanceof Error ? error : new Error(String(error));
297
307
  this.logger.error({
@@ -390,10 +400,37 @@ export class CodexAppServerClient extends EventEmitter {
390
400
  }
391
401
  }
392
402
  writeMessage(message) {
393
- if (!this.child?.stdin) {
394
- throw new Error("Codex app-server stdin is unavailable");
403
+ const child = this.child;
404
+ const stdin = child?.stdin;
405
+ if (!stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) {
406
+ const error = new Error("Codex app-server stdin is closed");
407
+ this.handleTransportFailure(error);
408
+ throw error;
395
409
  }
396
- this.child.stdin.write(`${JSON.stringify(message)}\n`);
410
+ try {
411
+ stdin.write(`${JSON.stringify(message)}\n`, (error) => {
412
+ if (error) {
413
+ this.handleTransportFailure(error instanceof Error ? error : new Error(String(error)));
414
+ }
415
+ });
416
+ }
417
+ catch (error) {
418
+ const err = error instanceof Error ? error : new Error(String(error));
419
+ this.handleTransportFailure(err);
420
+ throw err;
421
+ }
422
+ }
423
+ handleTransportFailure(error) {
424
+ const child = this.child;
425
+ this.started = false;
426
+ this.child = undefined;
427
+ this.stdoutBuffer = "";
428
+ this.logger.error({
429
+ error: sanitizeDiagnosticText(error.message),
430
+ pendingRequestCount: this.pending.size,
431
+ }, "Codex app-server transport failed");
432
+ this.rejectAllPending(error);
433
+ child?.kill("SIGTERM");
397
434
  }
398
435
  drainMessages() {
399
436
  while (true) {
@@ -134,7 +134,7 @@ export class RunOrchestrator {
134
134
  this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
135
135
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
136
136
  this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
137
- this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
137
+ this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
138
138
  this.runWakePlanner = new RunWakePlanner(db);
139
139
  this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed);
140
140
  this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, this.wakeDispatcher, logger, feed);
@@ -6,6 +6,7 @@ import { getThreadTurns } from "./codex-thread-utils.js";
6
6
  import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
7
7
  import { resolveEffectiveActiveRun } from "./effective-active-run.js";
8
8
  import { isThreadMaterializingError } from "./codex-thread-errors.js";
9
+ import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
9
10
  const THREAD_MATERIALIZATION_GRACE_MS = 10 * 60_000;
10
11
  function isWithinThreadMaterializationGrace(run, nowMs = Date.now()) {
11
12
  const startedAtMs = Date.parse(run.startedAt);
@@ -24,8 +25,9 @@ export class RunReconciler {
24
25
  releaseLease;
25
26
  readThreadWithRetry;
26
27
  recoverOrEscalate;
28
+ resolveRepoFullName;
27
29
  feed;
28
- constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, feed) {
30
+ constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, resolveRepoFullName = () => undefined, feed) {
29
31
  this.db = db;
30
32
  this.logger = logger;
31
33
  this.linearProvider = linearProvider;
@@ -36,6 +38,7 @@ export class RunReconciler {
36
38
  this.releaseLease = releaseLease;
37
39
  this.readThreadWithRetry = readThreadWithRetry;
38
40
  this.recoverOrEscalate = recoverOrEscalate;
41
+ this.resolveRepoFullName = resolveRepoFullName;
39
42
  this.feed = feed;
40
43
  }
41
44
  async reconcile(params) {
@@ -76,6 +79,9 @@ export class RunReconciler {
76
79
  this.releaseLease(run.projectId, run.linearIssueId);
77
80
  return;
78
81
  }
82
+ if (await this.releaseRunIfPullRequestMerged(run, effectiveIssue)) {
83
+ return;
84
+ }
79
85
  if (!run.threadId) {
80
86
  if (recoveryLease === "owned") {
81
87
  this.logger.debug({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
@@ -171,6 +177,56 @@ export class RunReconciler {
171
177
  this.releaseLease(run.projectId, run.linearIssueId);
172
178
  }
173
179
  }
180
+ async releaseRunIfPullRequestMerged(run, issue) {
181
+ if (issue.prNumber === undefined)
182
+ return false;
183
+ if (issue.prState === "merged") {
184
+ this.releaseMergedRun(run, issue, "Cached PR state is merged");
185
+ return true;
186
+ }
187
+ const repoFullName = this.resolveRepoFullName(issue.projectId);
188
+ if (!repoFullName)
189
+ return false;
190
+ const snapshot = await fetchPullRequestSnapshot(repoFullName, issue.prNumber);
191
+ if (!snapshot.ok) {
192
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: snapshot.error.message }, "Could not refresh active-run PR state during reconciliation");
193
+ return false;
194
+ }
195
+ if (snapshot.pr.state !== "MERGED")
196
+ return false;
197
+ this.releaseMergedRun(run, issue, "Pull request merged while the active Codex run was still marked running");
198
+ return true;
199
+ }
200
+ releaseMergedRun(run, issue, reason) {
201
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
202
+ this.db.issueSessions.clearPendingIssueSessionEvents(run.projectId, run.linearIssueId);
203
+ this.db.runs.finishRun(run.id, {
204
+ status: "released",
205
+ failureReason: reason,
206
+ });
207
+ this.db.issues.upsertIssue({
208
+ projectId: run.projectId,
209
+ linearIssueId: run.linearIssueId,
210
+ activeRunId: null,
211
+ factoryState: "done",
212
+ prState: "merged",
213
+ pendingRunType: null,
214
+ pendingRunContextJson: null,
215
+ });
216
+ });
217
+ this.feed?.publish({
218
+ level: "info",
219
+ kind: "stage",
220
+ issueKey: issue.issueKey,
221
+ projectId: run.projectId,
222
+ stage: "done",
223
+ status: "reconciled",
224
+ summary: `Released active ${run.runType} run after PR merge`,
225
+ });
226
+ const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
227
+ void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
228
+ this.releaseLease(run.projectId, run.linearIssueId);
229
+ }
174
230
  async confirmDelegationAuthorityBeforeRelease(run, issue) {
175
231
  const installation = this.db.linearInstallations.getLinearInstallationForProject(run.projectId);
176
232
  const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.68.4",
3
+ "version": "0.68.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {