patchrelay 0.51.4 → 0.52.0

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.51.4",
4
- "commit": "68e71c39b593",
5
- "builtAt": "2026-04-22T04:33:28.283Z"
3
+ "version": "0.52.0",
4
+ "commit": "818b35cc9b05",
5
+ "builtAt": "2026-04-22T14:41:18.426Z"
6
6
  }
@@ -5,8 +5,6 @@ import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGit
5
5
  import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
6
6
  export async function maybeEnqueueGitHubReactiveRun(params) {
7
7
  const { issue, event, project, logger, feed, enqueueIssue, db, fetchImpl, failureContextResolver } = params;
8
- if (issue.activeRunId !== undefined)
9
- return;
10
8
  if (isIssueTerminal(issue))
11
9
  return;
12
10
  if (!issue.delegatedToPatchRelay) {
@@ -22,6 +20,9 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
22
20
  return;
23
21
  }
24
22
  if (event.triggerEvent === "check_failed" && issue.prState === "open") {
23
+ if (issue.activeRunId !== undefined) {
24
+ return;
25
+ }
25
26
  await handleCheckFailedEvent({
26
27
  db,
27
28
  logger,
@@ -175,8 +176,14 @@ async function handleRequestedChangesEvent(params) {
175
176
  });
176
177
  const queuedRunType = hadPendingWake
177
178
  ? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
178
- : enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
179
- logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
179
+ : issue.activeRunId === undefined
180
+ ? enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId)
181
+ : db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType;
182
+ logger.info({
183
+ issueKey: issue.issueKey,
184
+ reviewerName: event.reviewerName,
185
+ deferredUntilRunRelease: issue.activeRunId !== undefined,
186
+ }, "Captured requested-changes follow-up");
180
187
  feed?.publish({
181
188
  level: "warn",
182
189
  kind: "github",
@@ -184,7 +191,9 @@ async function handleRequestedChangesEvent(params) {
184
191
  projectId: issue.projectId,
185
192
  stage: "changes_requested",
186
193
  status: "review_fix_queued",
187
- summary: `${queuedRunType ?? "review_fix"} queued after requested changes`,
194
+ summary: issue.activeRunId === undefined
195
+ ? `${queuedRunType ?? "review_fix"} queued after requested changes`
196
+ : `${queuedRunType ?? "review_fix"} recorded and will resume after the active run finishes`,
188
197
  detail: reviewComments && reviewComments.length > 0
189
198
  ? `${reviewComments.length} inline review comment${reviewComments.length === 1 ? "" : "s"} captured`
190
199
  : event.reviewBody?.slice(0, 200) ?? event.reviewerName,
@@ -603,6 +603,30 @@ export class IdleIssueReconciler {
603
603
  });
604
604
  return;
605
605
  }
606
+ if (issue.delegatedToPatchRelay
607
+ && reactiveIntent?.runType === "review_fix"
608
+ && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined) {
609
+ this.logger.info({
610
+ issueKey: issue.issueKey,
611
+ prNumber: issue.prNumber,
612
+ from: issue.factoryState,
613
+ runType: reactiveIntent.runType,
614
+ }, "Reconciliation: re-queued requested-changes follow-up from GitHub truth");
615
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
616
+ pendingRunType: reactiveIntent.runType,
617
+ clearFailureProvenance: true,
618
+ });
619
+ this.feed?.publish({
620
+ level: "warn",
621
+ kind: "github",
622
+ issueKey: issue.issueKey,
623
+ projectId: issue.projectId,
624
+ stage: reactiveIntent.compatibilityFactoryState,
625
+ status: "review_fix_queued",
626
+ summary: `PR #${issue.prNumber} still has requested changes on the current head, dispatching review fix`,
627
+ });
628
+ return;
629
+ }
606
630
  if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
607
631
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
608
632
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
@@ -26,21 +26,24 @@ function deriveProgressFactFromCompletedItem(rawItem, issue) {
26
26
  return {
27
27
  kind: "verification_started",
28
28
  meaningKey: `verification:${normalizeMeaningKey(body)}`,
29
- content: { type: "thought", body },
29
+ ephemeralContent: { type: "thought", body },
30
+ historyContent: { type: "thought", body },
30
31
  };
31
32
  }
32
33
  if (looksLikePublishing(body)) {
33
34
  return {
34
35
  kind: "publishing_started",
35
36
  meaningKey: `publishing:${normalizeMeaningKey(body)}`,
36
- content: { type: "thought", body },
37
+ ephemeralContent: { type: "thought", body },
38
+ historyContent: { type: "thought", body },
37
39
  };
38
40
  }
39
41
  if (looksLikeRootCause(body)) {
40
42
  return {
41
43
  kind: "root_cause_found",
42
44
  meaningKey: `finding:${normalizeMeaningKey(body)}`,
43
- content: { type: "thought", body },
45
+ ephemeralContent: { type: "thought", body },
46
+ historyContent: { type: "thought", body },
44
47
  };
45
48
  }
46
49
  return undefined;
@@ -59,7 +62,12 @@ function deriveProgressFactFromPlan(rawPlan, issue) {
59
62
  return {
60
63
  kind: "verification_started",
61
64
  meaningKey: `verification:${normalizeMeaningKey(activeStep.step)}`,
62
- content: {
65
+ ephemeralContent: {
66
+ type: "action",
67
+ action: "Verifying",
68
+ parameter: summarizePlanStep(activeStep.step, "latest changes before publishing"),
69
+ },
70
+ historyContent: {
63
71
  type: "action",
64
72
  action: "Verifying",
65
73
  parameter: summarizePlanStep(activeStep.step, "latest changes before publishing"),
@@ -71,7 +79,12 @@ function deriveProgressFactFromPlan(rawPlan, issue) {
71
79
  return {
72
80
  kind: "publishing_started",
73
81
  meaningKey: `publishing:${normalizeMeaningKey(activeStep.step)}`,
74
- content: {
82
+ ephemeralContent: {
83
+ type: "action",
84
+ action: "Publishing",
85
+ parameter,
86
+ },
87
+ historyContent: {
75
88
  type: "action",
76
89
  action: "Publishing",
77
90
  parameter,
@@ -17,22 +17,79 @@ export class LinearProgressReporter {
17
17
  return;
18
18
  }
19
19
  const previous = this.publicationsByRun.get(run.id);
20
- if (previous?.meaningKey === fact.meaningKey) {
20
+ const shouldEmitEphemeral = previous?.ephemeralMeaningKey !== fact.meaningKey;
21
+ const shouldEmitHistory = previous?.historyMeaningKey !== fact.meaningKey;
22
+ if (!shouldEmitEphemeral && !shouldEmitHistory) {
21
23
  return;
22
24
  }
25
+ const now = Date.now();
23
26
  const publication = {
24
- meaningKey: fact.meaningKey,
25
- publishedAtMs: Date.now(),
27
+ ...previous,
28
+ ...(shouldEmitEphemeral
29
+ ? {
30
+ ephemeralMeaningKey: fact.meaningKey,
31
+ ephemeralPublishedAtMs: now,
32
+ }
33
+ : {}),
34
+ ...(shouldEmitHistory
35
+ ? {
36
+ historyMeaningKey: fact.meaningKey,
37
+ historyPublishedAtMs: now,
38
+ }
39
+ : {}),
26
40
  };
27
41
  this.publicationsByRun.set(run.id, publication);
28
- void this.emitActivity(issue, fact.content, { ephemeral: true }).catch(() => {
29
- const current = this.publicationsByRun.get(run.id);
30
- if (current?.publishedAtMs === publication.publishedAtMs && current.meaningKey === publication.meaningKey) {
31
- this.publicationsByRun.delete(run.id);
32
- }
33
- });
42
+ if (shouldEmitEphemeral) {
43
+ void this.emitActivity(issue, fact.ephemeralContent, { ephemeral: true }).catch(() => {
44
+ this.clearFailedPublication(run.id, "ephemeral", fact.meaningKey, now);
45
+ });
46
+ }
47
+ if (shouldEmitHistory) {
48
+ void this.emitActivity(issue, fact.historyContent).catch(() => {
49
+ this.clearFailedPublication(run.id, "history", fact.meaningKey, now);
50
+ });
51
+ }
34
52
  }
35
53
  clearProgress(runId) {
36
54
  this.publicationsByRun.delete(runId);
37
55
  }
56
+ clearFailedPublication(runId, channel, meaningKey, publishedAtMs) {
57
+ const current = this.publicationsByRun.get(runId);
58
+ if (!current) {
59
+ return;
60
+ }
61
+ if (channel === "ephemeral") {
62
+ if (current.ephemeralMeaningKey !== meaningKey || current.ephemeralPublishedAtMs !== publishedAtMs) {
63
+ return;
64
+ }
65
+ const next = {};
66
+ if (current.historyMeaningKey !== undefined) {
67
+ next.historyMeaningKey = current.historyMeaningKey;
68
+ }
69
+ if (current.historyPublishedAtMs !== undefined) {
70
+ next.historyPublishedAtMs = current.historyPublishedAtMs;
71
+ }
72
+ if (!next.historyMeaningKey) {
73
+ this.publicationsByRun.delete(runId);
74
+ return;
75
+ }
76
+ this.publicationsByRun.set(runId, next);
77
+ return;
78
+ }
79
+ if (current.historyMeaningKey !== meaningKey || current.historyPublishedAtMs !== publishedAtMs) {
80
+ return;
81
+ }
82
+ const next = {};
83
+ if (current.ephemeralMeaningKey !== undefined) {
84
+ next.ephemeralMeaningKey = current.ephemeralMeaningKey;
85
+ }
86
+ if (current.ephemeralPublishedAtMs !== undefined) {
87
+ next.ephemeralPublishedAtMs = current.ephemeralPublishedAtMs;
88
+ }
89
+ if (!next.ephemeralMeaningKey) {
90
+ this.publicationsByRun.delete(runId);
91
+ return;
92
+ }
93
+ this.publicationsByRun.set(runId, next);
94
+ }
38
95
  }
@@ -135,6 +135,26 @@ export class RunFinalizer {
135
135
  this.linearSync.clearProgress(run.id);
136
136
  this.releaseLease(run.projectId, run.linearIssueId);
137
137
  }
138
+ enqueuePendingWakeIfPresent(params) {
139
+ const wake = this.db.issueSessions.peekIssueSessionWake(params.run.projectId, params.run.linearIssueId);
140
+ if (!wake)
141
+ return undefined;
142
+ this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
143
+ this.feed?.publish({
144
+ level: "info",
145
+ kind: "stage",
146
+ issueKey: params.issueKey,
147
+ projectId: params.run.projectId,
148
+ stage: wake.runType,
149
+ status: "deferred_follow_up_queued",
150
+ summary: `${wake.runType} queued after ${params.run.runType} released authority`,
151
+ ...(wake.wakeReason ? { detail: `wake reason: ${wake.wakeReason}` } : {}),
152
+ });
153
+ return {
154
+ runType: wake.runType,
155
+ ...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
156
+ };
157
+ }
138
158
  publishTurnEvent(params) {
139
159
  this.feed?.publish({
140
160
  level: params.level,
@@ -294,7 +314,6 @@ export class RunFinalizer {
294
314
  status: "follow_up_queued",
295
315
  summary: postRunFollowUp.summary,
296
316
  });
297
- this.enqueueIssue(run.projectId, run.linearIssueId);
298
317
  }
299
318
  this.publishTurnEvent({
300
319
  level: "info",
@@ -320,6 +339,7 @@ export class RunFinalizer {
320
339
  void this.linearSync.emitActivity(updatedIssue, linearActivity);
321
340
  }
322
341
  void this.linearSync.syncSession(updatedIssue);
342
+ this.enqueuePendingWakeIfPresent({ run, issueKey: updatedIssue.issueKey });
323
343
  this.linearSync.clearProgress(run.id);
324
344
  this.releaseLease(run.projectId, run.linearIssueId);
325
345
  }
@@ -1,4 +1,5 @@
1
1
  import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
+ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
2
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
3
4
  export class ServiceStartupRecovery {
4
5
  db;
@@ -89,6 +90,7 @@ export class ServiceStartupRecovery {
89
90
  }
90
91
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
91
92
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
93
+ const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined;
92
94
  const shouldRecoverPausedLocalWork = delegated
93
95
  && isResumablePausedLocalWork({
94
96
  issue: {
@@ -97,7 +99,21 @@ export class ServiceStartupRecovery {
97
99
  },
98
100
  latestRun,
99
101
  })
100
- && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
102
+ && !hasPendingWake;
103
+ const reactiveIntent = delegated && !hasPendingWake
104
+ ? deriveIssueSessionReactiveIntent({
105
+ delegatedToPatchRelay: delegated,
106
+ prNumber: issue.prNumber,
107
+ prState: issue.prState,
108
+ prIsDraft: issue.prIsDraft,
109
+ prReviewState: issue.prReviewState,
110
+ prCheckStatus: issue.prCheckStatus,
111
+ latestFailureSource: issue.lastGitHubFailureSource,
112
+ })
113
+ : undefined;
114
+ const shouldRecoverReactivePrWork = delegated
115
+ && issue.prNumber !== undefined
116
+ && reactiveIntent !== undefined;
101
117
  const updated = this.db.issues.upsertIssue({
102
118
  projectId: issue.projectId,
103
119
  linearIssueId: issue.linearIssueId,
@@ -110,26 +126,64 @@ export class ServiceStartupRecovery {
110
126
  ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
111
127
  ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
112
128
  ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
113
- ...(shouldRecoverPausedLocalWork ? { factoryState: "delegated" } : {}),
129
+ ...(shouldRecoverPausedLocalWork
130
+ ? { factoryState: "delegated" }
131
+ : shouldRecoverReactivePrWork
132
+ ? { factoryState: reactiveIntent.compatibilityFactoryState }
133
+ : {}),
114
134
  });
115
- if (!shouldRecoverPausedLocalWork) {
135
+ if (!shouldRecoverPausedLocalWork && !shouldRecoverReactivePrWork) {
116
136
  continue;
117
137
  }
118
138
  if (unresolvedBlockers === 0) {
119
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
120
- projectId: issue.projectId,
121
- linearIssueId: issue.linearIssueId,
122
- eventType: "delegated",
123
- dedupeKey: `delegated:${issue.linearIssueId}`,
124
- });
139
+ if (shouldRecoverReactivePrWork && reactiveIntent) {
140
+ this.appendReactiveWakeEvent(issue.projectId, issue.linearIssueId, issue, reactiveIntent.runType);
141
+ }
142
+ else {
143
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
144
+ projectId: issue.projectId,
145
+ linearIssueId: issue.linearIssueId,
146
+ eventType: "delegated",
147
+ dedupeKey: `delegated:${issue.linearIssueId}`,
148
+ });
149
+ }
125
150
  if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
126
151
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
127
152
  }
128
- this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from paused local-work state and re-queued implementation");
153
+ this.logger.info({
154
+ issueKey: updated.issueKey,
155
+ ...(shouldRecoverReactivePrWork && reactiveIntent ? { runType: reactiveIntent.runType } : {}),
156
+ }, shouldRecoverReactivePrWork
157
+ ? "Recovered delegated PR issue from reactive paused state and re-queued follow-up work"
158
+ : "Recovered delegated issue from paused local-work state and re-queued implementation");
129
159
  }
130
160
  else {
131
- this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from paused local-work state");
161
+ this.logger.info({
162
+ issueKey: updated.issueKey,
163
+ unresolvedBlockers,
164
+ ...(shouldRecoverReactivePrWork && reactiveIntent ? { runType: reactiveIntent.runType } : {}),
165
+ }, shouldRecoverReactivePrWork
166
+ ? "Recovered delegated blocked PR issue from reactive paused state"
167
+ : "Recovered delegated blocked issue from paused local-work state");
132
168
  }
133
169
  }
134
170
  }
171
+ appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
172
+ const eventType = runType === "queue_repair"
173
+ ? "merge_steward_incident"
174
+ : runType === "ci_repair"
175
+ ? "settled_red_ci"
176
+ : "review_changes_requested";
177
+ const dedupeKey = runType === "queue_repair"
178
+ ? `startup_recovery:queue_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
179
+ : runType === "ci_repair"
180
+ ? `startup_recovery:ci_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
181
+ : `startup_recovery:${runType}:${linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
182
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
183
+ projectId,
184
+ linearIssueId,
185
+ eventType,
186
+ dedupeKey,
187
+ });
188
+ }
135
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.51.4",
3
+ "version": "0.52.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {