patchrelay 0.12.4 → 0.12.6

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.4",
4
- "commit": "dea4ce43848b",
5
- "builtAt": "2026-03-24T18:27:01.637Z"
3
+ "version": "0.12.6",
4
+ "commit": "62c29649ea46",
5
+ "builtAt": "2026-03-24T21:48:34.334Z"
6
6
  }
package/dist/db.js CHANGED
@@ -260,7 +260,7 @@ export class PatchRelayDatabase {
260
260
  }
261
261
  listRunningRuns() {
262
262
  const rows = this.connection
263
- .prepare("SELECT * FROM runs WHERE status = 'running'")
263
+ .prepare("SELECT * FROM runs WHERE status IN ('running', 'queued')")
264
264
  .all();
265
265
  return rows.map(mapRunRow);
266
266
  }
@@ -226,32 +226,32 @@ export class GitHubWebhookHandler {
226
226
  async emitLinearActivity(issue, newState, event) {
227
227
  if (!issue.agentSessionId)
228
228
  return;
229
- const linear = await this.linearProvider.forProject(issue.projectId);
230
- if (!linear?.createAgentActivity)
231
- return;
232
- const messages = {
233
- pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
234
- awaiting_queue: "PR approved. Preparing merge.",
235
- changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
236
- repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
237
- repairing_queue: "Merge conflict with base branch. Starting repair.",
238
- done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
239
- failed: "PR was closed without merging.",
240
- };
241
- const body = messages[newState];
242
- if (!body)
243
- return;
244
- const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
245
- ? "error"
246
- : "response";
247
229
  try {
230
+ const linear = await this.linearProvider.forProject(issue.projectId);
231
+ if (!linear?.createAgentActivity)
232
+ return;
233
+ const messages = {
234
+ pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
235
+ awaiting_queue: "PR approved. Preparing merge.",
236
+ changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
237
+ repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
238
+ repairing_queue: "Merge conflict with base branch. Starting repair.",
239
+ done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
240
+ failed: "PR was closed without merging.",
241
+ };
242
+ const body = messages[newState];
243
+ if (!body)
244
+ return;
245
+ const type = newState === "failed" || newState === "repairing_ci" || newState === "repairing_queue"
246
+ ? "error"
247
+ : "response";
248
248
  await linear.createAgentActivity({
249
249
  agentSessionId: issue.agentSessionId,
250
250
  content: { type, body },
251
251
  });
252
252
  }
253
253
  catch {
254
- // Non-blocking — don't fail the webhook for a Linear activity error
254
+ // Non-blocking — don't crash the webhook handler for a Linear activity error
255
255
  }
256
256
  }
257
257
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { ACTIVE_RUN_STATES } from "./factory-state.js";
3
4
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
4
5
  import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionPlan, } from "./agent-session-plan.js";
5
6
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
@@ -275,6 +276,22 @@ export class RunOrchestrator {
275
276
  // Complete the run
276
277
  const trackedIssue = this.db.issueToTrackedIssue(issue);
277
278
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
279
+ // Determine post-run state. When a re-run finds the PR already exists
280
+ // and makes no changes, no pr_opened webhook arrives — the state would
281
+ // stay in the active-run state forever. Advance based on PR metadata.
282
+ const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
283
+ let postRunState;
284
+ if (ACTIVE_RUN_STATES.has(freshIssue.factoryState) && freshIssue.prNumber) {
285
+ if (freshIssue.prReviewState === "approved") {
286
+ postRunState = "awaiting_queue";
287
+ }
288
+ else if (freshIssue.prState === "merged") {
289
+ postRunState = "done";
290
+ }
291
+ else {
292
+ postRunState = "pr_open";
293
+ }
294
+ }
278
295
  this.db.transaction(() => {
279
296
  this.db.finishRun(run.id, {
280
297
  status: "completed",
@@ -287,8 +304,14 @@ export class RunOrchestrator {
287
304
  projectId: run.projectId,
288
305
  linearIssueId: run.linearIssueId,
289
306
  activeRunId: null,
307
+ ...(postRunState ? { factoryState: postRunState } : {}),
308
+ ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
290
309
  });
291
310
  });
311
+ // If we advanced to awaiting_queue, enqueue for merge prep
312
+ if (postRunState === "awaiting_queue") {
313
+ this.enqueueIssue(run.projectId, run.linearIssueId);
314
+ }
292
315
  this.feed?.publish({
293
316
  level: "info",
294
317
  kind: "turn",
@@ -328,24 +351,49 @@ export class RunOrchestrator {
328
351
  }
329
352
  }
330
353
  async reconcileRun(run) {
331
- if (!run.threadId) {
332
- this.failRunAndClear(run, "Run has no thread ID during reconciliation");
333
- return;
334
- }
335
354
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
336
355
  if (!issue)
337
356
  return;
338
- // Read Codex state
357
+ // Zombie run: claimed in DB but Codex never started (no thread).
358
+ // This happens when the service crashes between claiming the run
359
+ // and starting the Codex turn. Re-enqueue instead of failing.
360
+ if (!run.threadId) {
361
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread) — clearing and re-enqueueing");
362
+ this.db.transaction(() => {
363
+ this.db.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
364
+ this.db.upsertIssue({
365
+ projectId: run.projectId,
366
+ linearIssueId: run.linearIssueId,
367
+ activeRunId: null,
368
+ pendingRunType: run.runType,
369
+ pendingRunContextJson: null,
370
+ });
371
+ });
372
+ this.enqueueIssue(run.projectId, run.linearIssueId);
373
+ return;
374
+ }
375
+ // Read Codex state — thread may not exist after app-server restart.
339
376
  let thread;
340
377
  try {
341
378
  thread = await this.readThreadWithRetry(run.threadId);
342
379
  }
343
380
  catch {
344
- this.failRunAndClear(run, "Codex thread not found during reconciliation");
381
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation clearing and re-enqueueing");
382
+ this.db.transaction(() => {
383
+ this.db.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
384
+ this.db.upsertIssue({
385
+ projectId: run.projectId,
386
+ linearIssueId: run.linearIssueId,
387
+ activeRunId: null,
388
+ pendingRunType: run.runType,
389
+ pendingRunContextJson: null,
390
+ });
391
+ });
392
+ this.enqueueIssue(run.projectId, run.linearIssueId);
345
393
  return;
346
394
  }
347
- // Check Linear state
348
- const linear = await this.linearProvider.forProject(run.projectId);
395
+ // Check Linear state (non-fatal — token refresh may fail)
396
+ const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
349
397
  if (linear) {
350
398
  const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
351
399
  if (linearIssue) {
@@ -429,10 +477,10 @@ export class RunOrchestrator {
429
477
  async emitLinearActivity(issue, type, body, options) {
430
478
  if (!issue.agentSessionId)
431
479
  return;
432
- const linear = await this.linearProvider.forProject(issue.projectId);
433
- if (!linear)
434
- return;
435
480
  try {
481
+ const linear = await this.linearProvider.forProject(issue.projectId);
482
+ if (!linear)
483
+ return;
436
484
  await linear.createAgentActivity({
437
485
  agentSessionId: issue.agentSessionId,
438
486
  content: { type, body },
@@ -446,10 +494,10 @@ export class RunOrchestrator {
446
494
  async updateLinearPlan(issue, plan) {
447
495
  if (!issue.agentSessionId)
448
496
  return;
449
- const linear = await this.linearProvider.forProject(issue.projectId);
450
- if (!linear?.updateAgentSession)
451
- return;
452
497
  try {
498
+ const linear = await this.linearProvider.forProject(issue.projectId);
499
+ if (!linear?.updateAgentSession)
500
+ return;
453
501
  await linear.updateAgentSession({ agentSessionId: issue.agentSessionId, plan });
454
502
  }
455
503
  catch (error) {
@@ -14,6 +14,7 @@ export class ServiceRuntime {
14
14
  webhookQueue;
15
15
  issueQueue;
16
16
  ready = false;
17
+ linearConnected = false;
17
18
  startupError;
18
19
  reconcileTimer;
19
20
  reconcileInProgress = false;
@@ -54,10 +55,14 @@ export class ServiceRuntime {
54
55
  enqueueIssue(projectId, issueId) {
55
56
  this.issueQueue.enqueue({ projectId, issueId });
56
57
  }
58
+ setLinearConnected(connected) {
59
+ this.linearConnected = connected;
60
+ }
57
61
  getReadiness() {
58
62
  return {
59
- ready: this.ready && this.codex.isStarted(),
63
+ ready: this.ready && this.codex.isStarted() && this.linearConnected,
60
64
  codexStarted: this.codex.isStarted(),
65
+ linearConnected: this.linearConnected,
61
66
  ...(this.startupError ? { startupError: this.startupError } : {}),
62
67
  };
63
68
  }
package/dist/service.js CHANGED
@@ -73,10 +73,37 @@ export class PatchRelayService {
73
73
  this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
74
74
  }
75
75
  this.codex.on("notification", (notification) => {
76
- void this.orchestrator.handleCodexNotification(notification);
76
+ this.orchestrator.handleCodexNotification(notification).catch((error) => {
77
+ const msg = error instanceof Error ? error.message : String(error);
78
+ logger.error({ method: notification.method, error: msg }, "Unhandled error in Codex notification handler");
79
+ });
77
80
  });
78
81
  }
79
82
  async start() {
83
+ // Verify Linear connectivity for all configured projects before starting.
84
+ // Auth errors do not prevent startup (the OAuth callback must be reachable
85
+ // for `patchrelay connect`), but the service reports NOT READY until at
86
+ // least one project has a working Linear token.
87
+ let anyLinearConnected = false;
88
+ for (const project of this.config.projects) {
89
+ try {
90
+ const client = await this.linearProvider.forProject(project.id);
91
+ if (client) {
92
+ anyLinearConnected = true;
93
+ }
94
+ else {
95
+ this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay connect' to authorize");
96
+ }
97
+ }
98
+ catch (error) {
99
+ const msg = error instanceof Error ? error.message : String(error);
100
+ this.logger.error({ projectId: project.id, error: msg }, "Linear auth failed — run 'patchrelay connect' to refresh the token. Runs for this project will fail until re-authorized.");
101
+ }
102
+ }
103
+ this.runtime.setLinearConnected(anyLinearConnected);
104
+ if (!anyLinearConnected && this.config.projects.length > 0) {
105
+ this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay connect' to authorize.");
106
+ }
80
107
  if (this.githubAppTokenManager) {
81
108
  await ensureGhWrapper(this.logger);
82
109
  await this.githubAppTokenManager.start();
@@ -96,7 +123,11 @@ export class PatchRelayService {
96
123
  return await this.oauthService.createStart(params);
97
124
  }
98
125
  async completeLinearOAuth(params) {
99
- return await this.oauthService.complete(params);
126
+ const result = await this.oauthService.complete(params);
127
+ // A successful OAuth completion means at least one project now has
128
+ // working Linear auth — update readiness.
129
+ this.runtime.setLinearConnected(true);
130
+ return result;
100
131
  }
101
132
  getLinearOAuthStateStatus(state) {
102
133
  return this.oauthService.getStateStatus(state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {