patchrelay 0.12.6 → 0.12.8

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.12.6",
4
- "commit": "62c29649ea46",
5
- "builtAt": "2026-03-24T21:48:34.334Z"
3
+ "version": "0.12.8",
4
+ "commit": "711863ef2d94",
5
+ "builtAt": "2026-03-24T22:49:07.152Z"
6
6
  }
@@ -48,6 +48,15 @@ export class CodexAppServerClient extends EventEmitter {
48
48
  this.child = this.spawnProcess(launch.command, launch.args, {
49
49
  stdio: ["pipe", "pipe", "pipe"],
50
50
  });
51
+ this.child.stdin.on("error", (error) => {
52
+ this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdin error");
53
+ });
54
+ this.child.stdout.on("error", (error) => {
55
+ this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdout error");
56
+ });
57
+ this.child.stderr.on("error", (error) => {
58
+ this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stderr error");
59
+ });
51
60
  this.child.stderr.on("data", (chunk) => {
52
61
  const line = chunk.toString().trim();
53
62
  if (line) {
@@ -75,6 +84,13 @@ export class CodexAppServerClient extends EventEmitter {
75
84
  });
76
85
  this.child.stdout.on("data", (chunk) => {
77
86
  this.stdoutBuffer += chunk.toString("utf8");
87
+ if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
88
+ this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
89
+ this.stdoutBuffer = "";
90
+ this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
91
+ this.child?.kill("SIGTERM");
92
+ return;
93
+ }
78
94
  this.drainMessages();
79
95
  });
80
96
  const initializeResponse = await this.sendRequest("initialize", {
@@ -96,14 +112,24 @@ export class CodexAppServerClient extends EventEmitter {
96
112
  this.started = true;
97
113
  }
98
114
  async stop() {
99
- if (!this.child) {
115
+ const child = this.child;
116
+ if (!child) {
100
117
  return;
101
118
  }
102
119
  this.logger.info("Stopping Codex app-server");
103
120
  this.stopping = true;
104
- this.child.kill("SIGTERM");
105
- this.child = undefined;
106
121
  this.started = false;
122
+ const exited = new Promise((resolve) => {
123
+ child.on("close", () => resolve());
124
+ });
125
+ child.kill("SIGTERM");
126
+ this.child = undefined;
127
+ // Wait for the child to exit, but don't block shutdown forever.
128
+ const timeout = new Promise((resolve) => {
129
+ const timer = setTimeout(resolve, 10_000);
130
+ timer.unref?.();
131
+ });
132
+ await Promise.race([exited, timeout]);
107
133
  }
108
134
  async startThread(options) {
109
135
  const params = {
package/dist/db.js CHANGED
@@ -217,6 +217,16 @@ export class PatchRelayDatabase {
217
217
  linearIssueId: String(row.linear_issue_id),
218
218
  }));
219
219
  }
220
+ /**
221
+ * Issues idle in pr_open with no active run — candidates for state
222
+ * advancement based on stored PR metadata (missed GitHub webhooks).
223
+ */
224
+ listIdlePrOpenIssues() {
225
+ const rows = this.connection
226
+ .prepare("SELECT * FROM issues WHERE factory_state = 'pr_open' AND active_run_id IS NULL AND pending_run_type IS NULL AND pr_number IS NOT NULL")
227
+ .all();
228
+ return rows.map(mapIssueRow);
229
+ }
220
230
  listIssuesByState(projectId, state) {
221
231
  const rows = this.connection
222
232
  .prepare("SELECT * FROM issues WHERE project_id = ? AND factory_state = ? ORDER BY pr_number ASC")
package/dist/http.js CHANGED
@@ -312,6 +312,12 @@ export async function buildHttpServer(config, service, logger) {
312
312
  for (const event of service.listOperatorFeed(feedQuery)) {
313
313
  writeEvent(event);
314
314
  }
315
+ const cleanup = () => {
316
+ clearInterval(keepAlive);
317
+ unsubscribe();
318
+ if (!reply.raw.destroyed)
319
+ reply.raw.end();
320
+ };
315
321
  const unsubscribe = service.subscribeOperatorFeed((event) => {
316
322
  if (!matchesOperatorFeedEvent(event, feedQuery)) {
317
323
  return;
@@ -321,11 +327,8 @@ export async function buildHttpServer(config, service, logger) {
321
327
  const keepAlive = setInterval(() => {
322
328
  reply.raw.write(": keepalive\n\n");
323
329
  }, 15000);
324
- request.raw.on("close", () => {
325
- clearInterval(keepAlive);
326
- unsubscribe();
327
- reply.raw.end();
328
- });
330
+ reply.raw.on("error", cleanup);
331
+ request.raw.on("close", cleanup);
329
332
  });
330
333
  app.get("/api/installations", async (_request, reply) => {
331
334
  return reply.send({ ok: true, installations: service.listLinearInstallations() });
package/dist/index.js CHANGED
@@ -62,15 +62,16 @@ async function main() {
62
62
  secrets: config.secretSources,
63
63
  }, "PatchRelay started");
64
64
  const shutdown = async () => {
65
- service.stop();
65
+ await service.stop();
66
66
  await app.close();
67
67
  };
68
- process.once("SIGINT", () => {
69
- void shutdown();
70
- });
71
- process.once("SIGTERM", () => {
72
- void shutdown();
73
- });
68
+ const onSignal = (signal) => {
69
+ shutdown().catch((error) => {
70
+ logger.error({ signal, error: error instanceof Error ? error.message : String(error) }, "Shutdown error");
71
+ }).finally(() => { process.exitCode ??= 0; });
72
+ };
73
+ process.once("SIGINT", () => onSignal("SIGINT"));
74
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
74
75
  }
75
76
  main().catch((error) => {
76
77
  process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
@@ -349,6 +349,57 @@ export class RunOrchestrator {
349
349
  for (const run of this.db.listRunningRuns()) {
350
350
  await this.reconcileRun(run);
351
351
  }
352
+ // Advance issues stuck in pr_open whose stored PR metadata already
353
+ // shows they should transition (e.g. approved PR, missed webhook).
354
+ await this.reconcileIdlePrOpenIssues();
355
+ }
356
+ async reconcileIdlePrOpenIssues() {
357
+ for (const issue of this.db.listIdlePrOpenIssues()) {
358
+ if (issue.prState === "merged") {
359
+ this.advanceIdleIssue(issue, "done");
360
+ continue;
361
+ }
362
+ if (issue.prReviewState === "approved") {
363
+ this.advanceIdleIssue(issue, "awaiting_queue");
364
+ continue;
365
+ }
366
+ // Stored metadata may be stale (missed webhooks during downtime).
367
+ // Query GitHub for the actual PR review state.
368
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
369
+ if (!project?.github?.repoFullName || !issue.prNumber)
370
+ continue;
371
+ try {
372
+ const { stdout } = await execCommand("gh", [
373
+ "pr", "view", String(issue.prNumber),
374
+ "--repo", project.github.repoFullName,
375
+ "--json", "state,reviewDecision",
376
+ ], { timeoutMs: 10_000 });
377
+ const pr = JSON.parse(stdout);
378
+ if (pr.state === "MERGED") {
379
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
380
+ this.advanceIdleIssue(issue, "done");
381
+ }
382
+ else if (pr.reviewDecision === "APPROVED") {
383
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
384
+ this.advanceIdleIssue(issue, "awaiting_queue");
385
+ }
386
+ }
387
+ catch (error) {
388
+ this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
389
+ }
390
+ }
391
+ }
392
+ advanceIdleIssue(issue, newState) {
393
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState }, "Reconciliation: advancing idle issue from stored PR metadata");
394
+ this.db.upsertIssue({
395
+ projectId: issue.projectId,
396
+ linearIssueId: issue.linearIssueId,
397
+ factoryState: newState,
398
+ ...(newState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
399
+ });
400
+ if (newState === "awaiting_queue") {
401
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
402
+ }
352
403
  }
353
404
  async reconcileRun(run) {
354
405
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
@@ -44,10 +44,10 @@ export class ServiceRuntime {
44
44
  throw error;
45
45
  }
46
46
  }
47
- stop() {
47
+ async stop() {
48
48
  this.ready = false;
49
49
  this.clearBackgroundReconcile();
50
- void this.codex.stop();
50
+ await this.codex.stop();
51
51
  }
52
52
  enqueueWebhookEvent(eventId, options) {
53
53
  this.webhookQueue.enqueue(eventId, options);
@@ -90,6 +90,11 @@ export class ServiceRuntime {
90
90
  this.reconcileInProgress = true;
91
91
  try {
92
92
  await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
93
+ // Pick up issues that became ready outside the webhook path
94
+ // (e.g. CLI retry, manual DB edits) without requiring a restart.
95
+ for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
96
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
97
+ }
93
98
  }
94
99
  catch (error) {
95
100
  this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
package/dist/service.js CHANGED
@@ -115,9 +115,9 @@ export class PatchRelayService {
115
115
  await this.runtime.start();
116
116
  this.mergeQueue.seedOnStartup();
117
117
  }
118
- stop() {
118
+ async stop() {
119
119
  this.githubAppTokenManager?.stop();
120
- this.runtime.stop();
120
+ await this.runtime.stop();
121
121
  }
122
122
  async createLinearOAuthStart(params) {
123
123
  return await this.oauthService.createStart(params);
@@ -230,7 +230,8 @@ export class WebhookHandler {
230
230
  summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
231
231
  });
232
232
  }
233
- catch {
233
+ catch (error) {
234
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up prompt");
234
235
  this.feed?.publish({
235
236
  level: "warn",
236
237
  kind: "agent",
@@ -288,7 +289,8 @@ export class WebhookHandler {
288
289
  summary: `Delivered follow-up comment to active ${run.runType} workflow`,
289
290
  });
290
291
  }
291
- catch {
292
+ catch (error) {
293
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
292
294
  this.feed?.publish({
293
295
  level: "warn",
294
296
  kind: "comment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {