patchrelay 0.35.5 → 0.35.7

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.35.5",
4
- "commit": "9abdef5af6a1",
5
- "builtAt": "2026-04-03T11:16:59.270Z"
3
+ "version": "0.35.7",
4
+ "commit": "880bc08921ac",
5
+ "builtAt": "2026-04-03T11:50:11.628Z"
6
6
  }
@@ -461,6 +461,7 @@ export class RunOrchestrator {
461
461
  projectId: run.projectId,
462
462
  linearIssueId: run.linearIssueId,
463
463
  activeRunId: null,
464
+ ...(postRunState === "awaiting_queue" ? { queueLabelApplied: false } : {}),
464
465
  ...(postRunState ? { factoryState: postRunState } : {}),
465
466
  ...(postRunState === "awaiting_queue" || postRunState === "done"
466
467
  ? {
@@ -742,11 +743,12 @@ export class RunOrchestrator {
742
743
  const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
743
744
  const inferProtocol = resolveMergeQueueProtocol(inferProject);
744
745
  let inferred = "branch_ci";
745
- if (inferProject?.github?.repoFullName && issue.prNumber && issue.lastGitHubFailureHeadSha) {
746
+ const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
747
+ if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
746
748
  try {
747
749
  const { stdout } = await execCommand("gh", [
748
750
  "api",
749
- `repos/${inferProject.github.repoFullName}/commits/${issue.lastGitHubFailureHeadSha}/check-runs`,
751
+ `repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
750
752
  "--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
751
753
  ], { timeoutMs: 10_000 });
752
754
  if (stdout.trim().length > 0)
@@ -823,6 +825,9 @@ export class RunOrchestrator {
823
825
  return;
824
826
  }
825
827
  this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
828
+ // Reset queueLabelApplied when entering or leaving awaiting_queue so
829
+ // the retry loop re-applies the label on each queue cycle.
830
+ const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
826
831
  this.db.upsertIssue({
827
832
  projectId: issue.projectId,
828
833
  linearIssueId: issue.linearIssueId,
@@ -833,6 +838,7 @@ export class RunOrchestrator {
833
838
  pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
834
839
  }
835
840
  : {}),
841
+ ...(resetQueueLabel ? { queueLabelApplied: false } : {}),
836
842
  ...(options?.clearFailureProvenance
837
843
  ? {
838
844
  lastGitHubFailureSource: null,
@@ -1068,6 +1074,7 @@ export class RunOrchestrator {
1068
1074
  projectId: run.projectId,
1069
1075
  linearIssueId: run.linearIssueId,
1070
1076
  activeRunId: null,
1077
+ ...(postRunState === "awaiting_queue" ? { queueLabelApplied: false } : {}),
1071
1078
  ...(postRunState ? { factoryState: postRunState } : {}),
1072
1079
  ...(postRunState === "awaiting_queue" || postRunState === "done"
1073
1080
  ? {
@@ -185,74 +185,74 @@ export class WebhookHandler {
185
185
  if (!normalizedIssue) {
186
186
  return { issue: undefined, desiredStage: undefined, delegated: false };
187
187
  }
188
+ // ── 1. Fetch data ────────────────────────────────────────────
188
189
  const existingIssue = this.db.getIssue(project.id, normalizedIssue.id);
189
190
  const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
190
191
  const delegated = this.isDelegatedToPatchRelay(project, normalized);
191
192
  const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
192
- const shouldTrack = Boolean(existingIssue || delegated);
193
- if (!shouldTrack) {
193
+ if (!existingIssue && !delegated) {
194
194
  return { issue: undefined, desiredStage: undefined, delegated };
195
195
  }
196
196
  const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
197
197
  const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
198
198
  const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
199
- const terminalForAutomation = isTerminalDelegationState(existingIssue, hydratedIssue);
200
- let pendingRunType;
201
- if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType && !terminalForAutomation) {
202
- pendingRunType = "implementation";
203
- }
204
- let clearPendingImplementation = unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun;
205
- // Release active run when issue reaches a terminal state or is un-delegated.
206
- let clearActiveRun = false;
207
- if (activeRun && existingIssue) {
208
- if (terminalForAutomation) {
209
- clearActiveRun = true;
210
- }
211
- if (normalized.triggerEvent === "delegateChanged" && !delegated) {
212
- clearActiveRun = true;
213
- clearPendingImplementation = Boolean(existingIssue.pendingRunType);
214
- }
215
- }
216
- // Un-delegation: transition to awaiting_input unless past point of no return.
217
- // awaiting_queue means the PR is approved and in the merge queue — let it merge.
218
- let undelegatedFactoryState;
219
- if (normalized.triggerEvent === "delegateChanged" && !delegated && existingIssue) {
220
- const pastNoReturn = existingIssue.factoryState === "awaiting_queue"
221
- || TERMINAL_STATES.has(existingIssue.factoryState);
222
- if (!pastNoReturn) {
223
- undelegatedFactoryState = "awaiting_input";
224
- clearPendingImplementation = Boolean(existingIssue.pendingRunType);
199
+ const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
200
+ // ── 2. Pure decisions ────────────────────────────────────────
201
+ const pendingRunType = decideRunIntent({
202
+ delegated, triggerAllowed, unresolvedBlockers,
203
+ hasActiveRun: Boolean(activeRun),
204
+ hasPendingRun: Boolean(existingIssue?.pendingRunType),
205
+ terminal,
206
+ });
207
+ const runRelease = decideActiveRunRelease({
208
+ hasActiveRun: Boolean(activeRun),
209
+ terminal,
210
+ triggerEvent: normalized.triggerEvent,
211
+ delegated,
212
+ });
213
+ const undelegation = decideUnDelegation({
214
+ triggerEvent: normalized.triggerEvent,
215
+ delegated,
216
+ currentState: existingIssue?.factoryState,
217
+ });
218
+ const clearPending = (unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun)
219
+ || undelegation.clearPending;
220
+ const agentSessionId = decideAgentSession({
221
+ sessionId: normalized.agentSession?.id,
222
+ hasActiveRun: Boolean(activeRun),
223
+ hasPendingRun: Boolean(pendingRunType),
224
+ triggerEvent: normalized.triggerEvent,
225
+ delegated,
226
+ });
227
+ // ── 3. Transactional commit ──────────────────────────────────
228
+ const issue = this.db.transaction(() => {
229
+ const record = this.db.upsertIssue({
230
+ projectId: project.id,
231
+ linearIssueId: normalizedIssue.id,
232
+ ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
233
+ ...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
234
+ ...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
235
+ ...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
236
+ ...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
237
+ ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
238
+ ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
239
+ ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
240
+ ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
241
+ ...(clearPending ? { pendingRunType: null } : {}),
242
+ ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
243
+ ? { pendingRunContextJson }
244
+ : {}),
245
+ ...(agentSessionId !== undefined ? { agentSessionId } : {}),
246
+ ...(runRelease.release ? { activeRunId: null } : {}),
247
+ ...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
248
+ });
249
+ if (runRelease.release && activeRun && runRelease.reason) {
250
+ this.db.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
225
251
  }
226
- }
227
- // Resolve agent session
228
- const agentSessionId = normalized.agentSession?.id ??
229
- (!activeRun && (pendingRunType || (normalized.triggerEvent === "delegateChanged" && !delegated)) ? null : undefined);
230
- // Upsert the issue
231
- const issue = this.db.upsertIssue({
232
- projectId: project.id,
233
- linearIssueId: normalizedIssue.id,
234
- ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
235
- ...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
236
- ...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
237
- ...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
238
- ...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
239
- ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
240
- ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
241
- ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
242
- ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
243
- ...(clearPendingImplementation ? { pendingRunType: null } : {}),
244
- ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
245
- ? { pendingRunContextJson }
246
- : {}),
247
- ...(agentSessionId !== undefined ? { agentSessionId } : {}),
248
- ...(clearActiveRun ? { activeRunId: null } : {}),
249
- ...(undelegatedFactoryState ? { factoryState: undelegatedFactoryState } : {}),
252
+ return record;
250
253
  });
251
- if (clearActiveRun && activeRun) {
252
- const reason = terminalForAutomation ? "Issue reached terminal state during active run" : "Un-delegated from PatchRelay";
253
- this.db.finishRun(activeRun.id, { status: "released", failureReason: reason });
254
- }
255
- if (undelegatedFactoryState) {
254
+ // ── 4. Side effects (after transaction) ──────────────────────
255
+ if (undelegation.factoryState) {
256
256
  this.feed?.publish({
257
257
  level: "warn",
258
258
  kind: "stage",
@@ -612,6 +612,41 @@ export class WebhookHandler {
612
612
  return undefined;
613
613
  }
614
614
  }
615
+ // ─── Pure decision functions for recordDesiredStage ──────────────
616
+ function decideRunIntent(p) {
617
+ if (p.delegated && p.triggerAllowed && p.unresolvedBlockers === 0
618
+ && !p.hasActiveRun && !p.hasPendingRun && !p.terminal) {
619
+ return "implementation";
620
+ }
621
+ return undefined;
622
+ }
623
+ function decideActiveRunRelease(p) {
624
+ if (!p.hasActiveRun)
625
+ return { release: false };
626
+ if (p.terminal)
627
+ return { release: true, reason: "Issue reached terminal state during active run" };
628
+ if (p.triggerEvent === "delegateChanged" && !p.delegated)
629
+ return { release: true, reason: "Un-delegated from PatchRelay" };
630
+ return { release: false };
631
+ }
632
+ function decideUnDelegation(p) {
633
+ if (p.triggerEvent !== "delegateChanged" || p.delegated)
634
+ return { clearPending: false };
635
+ if (!p.currentState)
636
+ return { clearPending: false };
637
+ const pastNoReturn = p.currentState === "awaiting_queue" || TERMINAL_STATES.has(p.currentState);
638
+ if (pastNoReturn)
639
+ return { clearPending: false };
640
+ return { factoryState: "awaiting_input", clearPending: true };
641
+ }
642
+ function decideAgentSession(p) {
643
+ if (p.sessionId)
644
+ return p.sessionId;
645
+ if (!p.hasActiveRun && (p.hasPendingRun || (p.triggerEvent === "delegateChanged" && !p.delegated)))
646
+ return null;
647
+ return undefined;
648
+ }
649
+ // ─── Helper predicates ──────────────────────────────────────────
615
650
  function isResolvedLinearState(stateType, stateName) {
616
651
  return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
617
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {