patchrelay 0.36.9 → 0.36.10

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.36.9",
4
- "commit": "0fdde7c6ee50",
5
- "builtAt": "2026-04-09T13:31:10.488Z"
3
+ "version": "0.36.10",
4
+ "commit": "35d99f024954",
5
+ "builtAt": "2026-04-09T16:10:38.473Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -221,6 +221,7 @@ export class CliDataAccess extends CliOperatorApiClient {
221
221
  .reverse()
222
222
  .map((run) => {
223
223
  const summary = summarizeRun(run);
224
+ const eventCount = this.db.runs.listThreadEvents(run.id).length;
224
225
  return {
225
226
  runId: run.id,
226
227
  runType: run.runType,
@@ -230,7 +231,8 @@ export class CliDataAccess extends CliOperatorApiClient {
230
231
  ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
231
232
  ...(summary ? { summary } : {}),
232
233
  ...(run.failureReason ? { failureReason: run.failureReason } : {}),
233
- eventCount: this.db.runs.listThreadEvents(run.id).length,
234
+ eventCount,
235
+ eventCountAvailable: this.config.runner.codex.persistExtendedHistory || eventCount > 0,
234
236
  startedAt: run.startedAt,
235
237
  ...(run.endedAt ? { endedAt: run.endedAt } : {}),
236
238
  isCurrentThread: run.threadId !== undefined && run.threadId === dbIssue.threadId,
@@ -106,7 +106,9 @@ export function formatSessionHistory(result, buildOpenForThread) {
106
106
  if (session.turnId) {
107
107
  lines.push(value("Turn", session.turnId));
108
108
  }
109
- lines.push(value("Events", session.eventCount));
109
+ lines.push(value("Events", session.eventCountAvailable
110
+ ? session.eventCount
111
+ : "not persisted (persistExtendedHistory=false)"));
110
112
  if (session.summary) {
111
113
  lines.push(value("Summary", truncateLine(session.summary)));
112
114
  }
@@ -136,6 +136,7 @@ export class GitHubWebhookHandler {
136
136
  return;
137
137
  }
138
138
  const project = this.config.projects.find((p) => p.id === issue.projectId);
139
+ const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
139
140
  // Update PR state on the issue
140
141
  this.db.issues.upsertIssue({
141
142
  projectId: issue.projectId,
@@ -146,7 +147,7 @@ export class GitHubWebhookHandler {
146
147
  ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
147
148
  ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
148
149
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
149
- ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
150
+ ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
150
151
  ...(event.reviewState === "changes_requested"
151
152
  ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
152
153
  : event.reviewState === "approved"
@@ -730,6 +731,21 @@ export class GitHubWebhookHandler {
730
731
  const normalized = event.checkName.trim().toLowerCase();
731
732
  return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
732
733
  }
734
+ deriveImmediatePrCheckStatus(issue, event, project) {
735
+ if (event.triggerEvent === "pr_synchronize") {
736
+ return "pending";
737
+ }
738
+ if (event.eventSource !== "check_run") {
739
+ return undefined;
740
+ }
741
+ if (!this.isGateCheckEvent(event, project)) {
742
+ return undefined;
743
+ }
744
+ if (this.isStaleGateEvent(issue, event)) {
745
+ return undefined;
746
+ }
747
+ return event.checkStatus;
748
+ }
733
749
  isStaleGateEvent(issue, event) {
734
750
  return Boolean(issue.lastGitHubCiSnapshotHeadSha
735
751
  && event.headSha
@@ -4,6 +4,7 @@ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
4
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
6
  import { execCommand } from "./utils.js";
7
+ const DEFAULT_REVIEW_FIX_BUDGET = 12;
7
8
  function isFailingCheckStatus(status) {
8
9
  return status === "failed" || status === "failure";
9
10
  }
@@ -493,6 +494,16 @@ export class IdleIssueReconciler {
493
494
  });
494
495
  if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
495
496
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
497
+ if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
498
+ this.logger.debug({
499
+ issueKey: issue.issueKey,
500
+ prNumber: issue.prNumber,
501
+ from: issue.factoryState,
502
+ runType: reactiveIntent.runType,
503
+ reviewFixAttempts: issue.reviewFixAttempts,
504
+ }, "Reconciliation: leaving terminal requested-changes issue escalated because the repair budget is exhausted");
505
+ return;
506
+ }
496
507
  const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
497
508
  ? buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
498
509
  : undefined;
@@ -14,12 +14,20 @@ import { RunFinalizer } from "./run-finalizer.js";
14
14
  import { RunLauncher } from "./run-launcher.js";
15
15
  import { RunRecoveryService } from "./run-recovery-service.js";
16
16
  import { RunWakePlanner } from "./run-wake-planner.js";
17
+ import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
17
18
  function lowerCaseFirst(value) {
18
19
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
19
20
  }
20
21
  function isRequestedChangesRunType(runType) {
21
22
  return runType === "review_fix" || runType === "branch_upkeep";
22
23
  }
24
+ function shouldDelayZombieRecoveryLaunch(issue, issueSession, runType) {
25
+ if (issue.zombieRecoveryAttempts <= 0)
26
+ return 0;
27
+ if (issueSession?.lastRunType !== runType)
28
+ return 0;
29
+ return getRemainingZombieRecoveryDelayMs(issue.lastZombieRecoveryAt, issue.zombieRecoveryAttempts);
30
+ }
23
31
  export class RunOrchestrator {
24
32
  config;
25
33
  db;
@@ -108,6 +116,12 @@ export class RunOrchestrator {
108
116
  return;
109
117
  }
110
118
  const { runType, context, resumeThread } = wake;
119
+ const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
120
+ if (remainingZombieDelayMs > 0) {
121
+ this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
122
+ this.releaseIssueSessionLease(item.projectId, item.issueId);
123
+ return;
124
+ }
111
125
  const effectiveContext = isRequestedChangesRunType(runType)
112
126
  ? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
113
127
  : context;
@@ -1,6 +1,6 @@
1
1
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
2
+ import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
2
3
  const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
3
- const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
4
4
  export class RunRecoveryService {
5
5
  db;
6
6
  logger;
@@ -108,10 +108,12 @@ export class RunRecoveryService {
108
108
  return;
109
109
  }
110
110
  if (fresh.lastZombieRecoveryAt) {
111
- const elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
112
- const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
113
- if (elapsed < delay) {
114
- this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
111
+ const remainingDelayMs = getRemainingZombieRecoveryDelayMs(fresh.lastZombieRecoveryAt, fresh.zombieRecoveryAttempts);
112
+ if (remainingDelayMs > 0) {
113
+ this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
114
+ this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
115
+ });
116
+ this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, remainingDelayMs }, "Recovery: backoff not elapsed, deferring retry");
115
117
  return;
116
118
  }
117
119
  }
@@ -0,0 +1,13 @@
1
+ const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
2
+ export function getZombieRecoveryDelayMs(recoveryAttempts) {
3
+ return ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, recoveryAttempts);
4
+ }
5
+ export function getRemainingZombieRecoveryDelayMs(lastRecoveryAt, recoveryAttempts, now = Date.now()) {
6
+ if (!lastRecoveryAt)
7
+ return 0;
8
+ const recoveredAtMs = Date.parse(lastRecoveryAt);
9
+ if (!Number.isFinite(recoveredAtMs))
10
+ return 0;
11
+ const delay = getZombieRecoveryDelayMs(recoveryAttempts);
12
+ return Math.max(0, recoveredAtMs + delay - now);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.9",
3
+ "version": "0.36.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {