patchrelay 0.37.0 → 0.37.1

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.37.0",
4
- "commit": "e927a73f2d7b",
5
- "builtAt": "2026-04-10T13:26:32.780Z"
3
+ "version": "0.37.1",
4
+ "commit": "e2c1f3d45497",
5
+ "builtAt": "2026-04-10T17:31:17.533Z"
6
6
  }
@@ -1,5 +1,6 @@
1
1
  import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
2
2
  import { ACTIVE_RUN_STATES } from "../factory-state.js";
3
+ import { hasOpenPr, resolveClosedPrDisposition } from "../pr-state.js";
3
4
  const RECONCILIATION_GRACE_MS = 120_000;
4
5
  const DOWNSTREAM_STALE_MS = 900_000;
5
6
  export async function collectClusterHealth(config, db, runCommand) {
@@ -98,7 +99,7 @@ export async function collectClusterHealth(config, db, runCommand) {
98
99
  }
99
100
  checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
100
101
  for (const snapshot of snapshots) {
101
- if (!snapshot.issue.prNumber) {
102
+ if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
102
103
  continue;
103
104
  }
104
105
  const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
@@ -277,19 +278,8 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
277
278
  const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
278
279
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
279
280
  const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
280
- const ciEntry = buildCiEntry({
281
- issue,
282
- gateCheckStatus,
283
- reviewDecision,
284
- reviewRequested,
285
- currentHeadSha: pr.headRefOid,
286
- latestBlockingReviewHeadSha,
287
- mergeConflictDetected,
288
- reviewQuillAttempt,
289
- });
290
281
  if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
291
282
  return {
292
- ciEntry,
293
283
  finding: {
294
284
  status: "fail",
295
285
  scope: "github:reconcile",
@@ -297,16 +287,29 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
297
287
  },
298
288
  };
299
289
  }
300
- if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
301
- return {
302
- ciEntry,
303
- finding: {
304
- status: "fail",
305
- scope: "github:reconcile",
306
- message: "PR is closed but the issue is still waiting on PR state",
307
- },
308
- };
290
+ if (pr.state === "CLOSED") {
291
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
292
+ if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
293
+ return {
294
+ finding: {
295
+ status: "fail",
296
+ scope: "github:reconcile",
297
+ message: "PR is closed but unfinished work has not been re-delegated",
298
+ },
299
+ };
300
+ }
301
+ return {};
309
302
  }
303
+ const ciEntry = buildCiEntry({
304
+ issue,
305
+ gateCheckStatus,
306
+ reviewDecision,
307
+ reviewRequested,
308
+ currentHeadSha: pr.headRefOid,
309
+ latestBlockingReviewHeadSha,
310
+ mergeConflictDetected,
311
+ reviewQuillAttempt,
312
+ });
310
313
  if (gateCheckStatus === "failure" && issue.factoryState !== "repairing_ci" && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
311
314
  return {
312
315
  ciEntry,
@@ -521,7 +524,7 @@ function needsReviewAutomation(issue) {
521
524
  if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
522
525
  return false;
523
526
  }
524
- return issue.prNumber !== undefined;
527
+ return hasOpenPr(issue.prNumber, issue.prState);
525
528
  }
526
529
  async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
527
530
  const owners = new Map();
package/dist/cli/data.js CHANGED
@@ -119,6 +119,7 @@ export class CliDataAccess extends CliOperatorApiClient {
119
119
  ...(latestReport ? { latestReport } : {}),
120
120
  ...(latestSummary ? { latestSummary } : {}),
121
121
  ...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
122
+ ...(dbIssue.prState ? { prState: dbIssue.prState } : {}),
122
123
  ...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
123
124
  ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
124
125
  ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
@@ -20,7 +20,11 @@ export function formatInspect(result) {
20
20
  value("Debug stage", result.issue?.factoryState),
21
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
22
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
23
- result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
23
+ result.prNumber
24
+ ? value("PR", `#${result.prNumber}${result.prState || result.prReviewState
25
+ ? ` [${[result.prState, result.prReviewState].filter(Boolean).join(", ")}]`
26
+ : ""}`)
27
+ : undefined,
24
28
  result.completionCheckOutcome ? value("Completion check", result.completionCheckOutcome) : undefined,
25
29
  result.completionCheckSummary ? value("Completion summary", truncateLine(result.completionCheckSummary)) : undefined,
26
30
  result.completionCheckQuestion ? value("Question", truncateLine(result.completionCheckQuestion)) : undefined,
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
5
  import { relativeTime, truncate } from "./format-utils.js";
5
6
  import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
@@ -19,7 +20,7 @@ function effectiveState(issue) {
19
20
  return "blocked";
20
21
  if (issue.sessionState === "waiting_input")
21
22
  return "awaiting_input";
22
- if (issue.prNumber !== undefined)
23
+ if (hasOpenPr(issue.prNumber, issue.prState))
23
24
  return issue.factoryState;
24
25
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
25
26
  return "ready";
@@ -94,7 +95,7 @@ function buildFacts(issue, selected) {
94
95
  else if (isChangesRequestedReviewState(issue.prReviewState)) {
95
96
  facts.push({ text: "changes requested", color: "yellow" });
96
97
  }
97
- else if (issue.prNumber !== undefined
98
+ else if (hasOpenPr(issue.prNumber, issue.prState)
98
99
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
99
100
  facts.push({ text: "awaiting review", color: "yellow" });
100
101
  }
@@ -146,7 +147,7 @@ function blockerText(issue) {
146
147
  return "Awaiting re-review after requested changes";
147
148
  if (isChangesRequestedReviewState(issue.prReviewState))
148
149
  return "Review changes requested";
149
- if (issue.prNumber !== undefined && isAwaitingReviewState(issue.prReviewState))
150
+ if (hasOpenPr(issue.prNumber, issue.prState) && isAwaitingReviewState(issue.prReviewState))
150
151
  return "Awaiting review";
151
152
  return null;
152
153
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { computeAggregates } from "./watch-state.js";
4
5
  import { FreshnessBadge } from "./FreshnessBadge.js";
5
6
  const FILTER_LABELS = {
@@ -11,7 +12,7 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
11
12
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
13
  const aggregateSource = filter === "all" ? allIssues : issues;
13
14
  const agg = computeAggregates(aggregateSource);
14
- const withPr = aggregateSource.filter((i) => i.prNumber !== undefined).length;
15
+ const withPr = aggregateSource.filter((i) => hasOpenPr(i.prNumber, i.prState)).length;
15
16
  const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
16
17
  const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
17
18
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  import { buildStateHistory } from "./history-builder.js";
2
3
  import { buildTimelineRows } from "./timeline-presentation.js";
3
4
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
@@ -396,7 +397,7 @@ function buildFactSegments(issue, issueContext) {
396
397
  facts.push([{ text: "re-review needed", color: "yellow" }]);
397
398
  else if (isChangesRequestedReviewState(issue.prReviewState))
398
399
  facts.push([{ text: "changes requested", color: "yellow" }]);
399
- else if (issue.prNumber !== undefined
400
+ else if (hasOpenPr(issue.prNumber, issue.prState)
400
401
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
401
402
  facts.push([{ text: "awaiting review", color: "yellow" }]);
402
403
  if (issue.factoryState === "awaiting_queue")
@@ -503,7 +504,7 @@ function effectiveState(issue) {
503
504
  return "blocked";
504
505
  if (issue.sessionState === "waiting_input")
505
506
  return "awaiting_input";
506
- if (issue.prNumber !== undefined)
507
+ if (hasOpenPr(issue.prNumber, issue.prState))
507
508
  return issue.factoryState;
508
509
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
509
510
  return "ready";
@@ -546,7 +547,7 @@ function blockerText(issue, issueContext) {
546
547
  return "Awaiting re-review after requested changes";
547
548
  if (isChangesRequestedReviewState(issue.prReviewState))
548
549
  return "Review changes requested";
549
- if (issue.prNumber !== undefined && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
550
+ if (hasOpenPr(issue.prNumber, issue.prState) && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
550
551
  return "Awaiting review";
551
552
  }
552
553
  return null;
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  function isPassingCheckStatus(status) {
2
3
  return status === "passed" || status === "success";
3
4
  }
@@ -72,7 +73,7 @@ export function prChecksFact(issue) {
72
73
  return undefined;
73
74
  }
74
75
  export function hasDisplayPrBlocker(issue) {
75
- if (issue.prNumber === undefined || issue.activeRunType) {
76
+ if (!hasOpenPr(issue.prNumber, issue.prState) || issue.activeRunType) {
76
77
  return false;
77
78
  }
78
79
  if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "../../pr-state.js";
1
2
  const STATE_LABELS = {
2
3
  delegated: "delegated",
3
4
  implementing: "implementing",
@@ -163,9 +164,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
163
164
  });
164
165
  }
165
166
  if (issue.prNumber !== undefined) {
167
+ const prLabel = hasOpenPr(issue.prNumber, issue.prState)
168
+ ? `Tracked PR: #${issue.prNumber}`
169
+ : `Tracked PR: #${issue.prNumber}${issue.prState ? ` (${issue.prState})` : ""}`;
166
170
  observations.push({
167
171
  tone: "info",
168
- text: `Tracked PR: #${issue.prNumber}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
172
+ text: `${prLabel}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
169
173
  });
170
174
  }
171
175
  return observations.slice(0, 3);
@@ -30,7 +30,7 @@ const TRANSITION_RULES = [
30
30
  // pr_closed during an active run is suppressed — Codex may reopen.
31
31
  // Without a guard match, the event produces no transition (undefined).
32
32
  { event: "pr_closed",
33
- guard: (_, ctx) => ctx.activeRunId === undefined,
33
+ guard: (s, ctx) => ctx.activeRunId === undefined && !TERMINAL_STATES.has(s),
34
34
  to: "failed" },
35
35
  // ── PR lifecycle ───────────────────────────────────────────────
36
36
  { event: "pr_opened",
@@ -1,4 +1,4 @@
1
- import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
1
+ import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { createGitHubCiSnapshotResolver, createGitHubFailureContextResolver, summarizeGitHubFailureContext, } from "./github-failure-context.js";
3
3
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
4
4
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
@@ -7,6 +7,7 @@ import { buildGitHubStateActivity } from "./linear-session-reporting.js";
7
7
  import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
8
8
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
9
9
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
10
+ import { buildClosedPrCleanupFields, isIssueTerminal, resolveClosedPrFactoryState, resolveClosedPrDisposition, } from "./pr-state.js";
10
11
  import { resolveSecret } from "./resolve-secret.js";
11
12
  import { safeJsonParse } from "./utils.js";
12
13
  /**
@@ -153,6 +154,9 @@ export class GitHubWebhookHandler {
153
154
  : event.reviewState === "approved"
154
155
  ? { lastBlockingReviewHeadSha: null }
155
156
  : {}),
157
+ ...(event.triggerEvent === "pr_closed"
158
+ ? buildClosedPrCleanupFields()
159
+ : {}),
156
160
  });
157
161
  await this.updateCiSnapshot(issue, event, project);
158
162
  await this.updateFailureProvenance(issue, event, project);
@@ -227,11 +231,14 @@ export class GitHubWebhookHandler {
227
231
  }
228
232
  }
229
233
  resolveFactoryStateForEvent(issue, event, project) {
234
+ if (event.triggerEvent === "pr_closed") {
235
+ return undefined;
236
+ }
230
237
  if (event.triggerEvent === "check_failed"
231
238
  && this.isQueueEvictionFailure(issue, event, project)
232
239
  && issue.prState === "open"
233
240
  && issue.activeRunId === undefined
234
- && !TERMINAL_STATES.has(issue.factoryState)) {
241
+ && !isIssueTerminal(issue)) {
235
242
  return "repairing_queue";
236
243
  }
237
244
  return resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState, {
@@ -318,7 +325,7 @@ export class GitHubWebhookHandler {
318
325
  return;
319
326
  // Don't trigger on terminal issues — late-arriving webhooks (e.g.
320
327
  // merge_group_failed after pr_merged) must not resurrect done issues.
321
- if (TERMINAL_STATES.has(issue.factoryState))
328
+ if (isIssueTerminal(issue))
322
329
  return;
323
330
  if (!this.isPatchRelayOwnedPr(issue)) {
324
331
  this.feed?.publish({
@@ -521,11 +528,14 @@ export class GitHubWebhookHandler {
521
528
  : "Pull request closed during active run",
522
529
  });
523
530
  }
531
+ const terminalFactoryState = event.triggerEvent === "pr_merged"
532
+ ? "done"
533
+ : resolveClosedPrFactoryState(issue);
524
534
  this.db.issues.upsertIssue({
525
535
  projectId: issue.projectId,
526
536
  linearIssueId: issue.linearIssueId,
527
537
  activeRunId: null,
528
- factoryState: event.triggerEvent === "pr_merged" ? "done" : "failed",
538
+ factoryState: terminalFactoryState,
529
539
  });
530
540
  };
531
541
  const activeLease = this.db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
@@ -537,6 +547,18 @@ export class GitHubWebhookHandler {
537
547
  }
538
548
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
539
549
  const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
550
+ if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
551
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
552
+ projectId: issue.projectId,
553
+ linearIssueId: issue.linearIssueId,
554
+ eventType: "delegated",
555
+ dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
556
+ });
557
+ this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
558
+ if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
559
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
560
+ }
561
+ }
540
562
  if (event.triggerEvent === "pr_merged") {
541
563
  await this.completeLinearIssueAfterMerge(updatedIssue);
542
564
  }
@@ -1,8 +1,10 @@
1
+ import {} from "./factory-state.js";
1
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
3
  import { parseGitHubFailureContext } from "./github-failure-context.js";
3
4
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
4
5
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
6
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
7
+ import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
6
8
  import { execCommand } from "./utils.js";
7
9
  const DEFAULT_REVIEW_FIX_BUDGET = 12;
8
10
  function isFailingCheckStatus(status) {
@@ -439,8 +441,23 @@ export class IdleIssueReconciler {
439
441
  return;
440
442
  }
441
443
  if (pr.state === "CLOSED") {
442
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed, re-delegating for implementation");
443
- this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
444
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
445
+ this.db.issues.upsertIssue({
446
+ projectId: issue.projectId,
447
+ linearIssueId: issue.linearIssueId,
448
+ prState: "closed",
449
+ ...buildClosedPrCleanupFields(),
450
+ });
451
+ if (closedPrDisposition === "done") {
452
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
453
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
454
+ return;
455
+ }
456
+ if (closedPrDisposition === "terminal") {
457
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
458
+ return;
459
+ }
460
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished work, re-delegating for implementation");
444
461
  this.advanceIdleIssue(issue, "delegated", {
445
462
  pendingRunType: "implementation",
446
463
  clearFailureProvenance: true,
@@ -46,7 +46,9 @@ export class ImplementationOutcomePolicy {
46
46
  ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
47
47
  ...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
48
48
  }, "published PR verification refresh");
49
- return undefined;
49
+ if (pr.state?.toLowerCase() !== "closed") {
50
+ return undefined;
51
+ }
50
52
  }
51
53
  }
52
54
  }
@@ -143,6 +143,7 @@ export class IssueOverviewQuery {
143
143
  factoryState: issueRecord?.factoryState ?? "delegated",
144
144
  pendingRunType: issueRecord?.pendingRunType,
145
145
  prNumber: session.prNumber,
146
+ prState: issueRecord?.prState,
146
147
  prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
147
148
  prReviewState: issueRecord?.prReviewState,
148
149
  prCheckStatus: issueRecord?.prCheckStatus,
@@ -159,6 +160,10 @@ export class IssueOverviewQuery {
159
160
  ...(issueRecord?.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
160
161
  sessionState: session.sessionState,
161
162
  factoryState: issueRecord?.factoryState ?? "delegated",
163
+ ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
164
+ ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
165
+ ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
166
+ ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
162
167
  blockedByCount: unresolvedBlockedBy.length,
163
168
  blockedByKeys,
164
169
  readyForExecution: isIssueSessionReadyForExecution({
@@ -1,5 +1,6 @@
1
1
  import { formatRunTypeLabel } from "./agent-session-plan.js";
2
2
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
3
+ import { isClosedPrState } from "./pr-state.js";
3
4
  function lowerRunTypeLabel(runType) {
4
5
  return formatRunTypeLabel(runType).toLowerCase();
5
6
  }
@@ -188,15 +189,19 @@ export function buildMergePrepEscalationActivity(attempts) {
188
189
  export function summarizeIssueStateForLinear(issue) {
189
190
  switch (issue.sessionState) {
190
191
  case "waiting_input":
191
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
192
193
  case "running":
193
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
194
195
  case "idle":
195
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
197
  case "done":
197
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
198
+ if (issue.prNumber && issue.prState === "merged")
199
+ return `PR #${issue.prNumber} has merged.`;
200
+ if (issue.prNumber && isClosedPrState(issue.prState))
201
+ return `Completed without merging PR #${issue.prNumber}.`;
202
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
198
203
  case "failed":
199
- return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
204
+ return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
200
205
  }
201
206
  switch (issue.factoryState) {
202
207
  case "pr_open":
@@ -204,7 +209,11 @@ export function summarizeIssueStateForLinear(issue) {
204
209
  case "awaiting_queue":
205
210
  return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
206
211
  case "done":
207
- return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
212
+ if (issue.prNumber && issue.prState === "merged")
213
+ return `PR #${issue.prNumber} has merged.`;
214
+ if (issue.prNumber && isClosedPrState(issue.prState))
215
+ return `Completed without merging PR #${issue.prNumber}.`;
216
+ return issue.prNumber ? `Completed with PR #${issue.prNumber}.` : "Completed.";
208
217
  default:
209
218
  return undefined;
210
219
  }
@@ -1,6 +1,7 @@
1
1
  import { extractCompletionCheck } from "./completion-check.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
4
+ import { isClosedPrState } from "./pr-state.js";
4
5
  export async function syncVisibleStatusComment(params) {
5
6
  const { db, issue, linear, logger, trackedIssue, options } = params;
6
7
  try {
@@ -31,7 +32,9 @@ export function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
31
32
  || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
32
33
  return true;
33
34
  }
34
- if ((issue.sessionState === "done" || issue.factoryState === "done") && issue.prNumber === undefined && !issue.prUrl) {
35
+ if ((issue.sessionState === "done" || issue.factoryState === "done")
36
+ && ((issue.prNumber === undefined && !issue.prUrl)
37
+ || isClosedPrState(issue.prState))) {
35
38
  return true;
36
39
  }
37
40
  return false;
@@ -49,6 +52,7 @@ function renderStatusComment(db, issue, trackedIssue, options) {
49
52
  factoryState: issue.factoryState,
50
53
  pendingRunType: issue.pendingRunType,
51
54
  ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
55
+ ...(issue.prState ? { prState: issue.prState } : {}),
52
56
  prHeadSha: issue.prHeadSha,
53
57
  prReviewState: issue.prReviewState,
54
58
  prCheckStatus: issue.prCheckStatus,
@@ -110,6 +114,10 @@ function statusHeadline(issue, activeRunType) {
110
114
  case "running":
111
115
  return issue.prNumber !== undefined ? `PR #${issue.prNumber} is actively running` : "Actively running";
112
116
  case "done":
117
+ if (issue.prNumber !== undefined && issue.prState === "merged")
118
+ return `Completed with merged PR #${issue.prNumber}`;
119
+ if (issue.prNumber !== undefined && isClosedPrState(issue.prState))
120
+ return `Completed without merging PR #${issue.prNumber}`;
113
121
  return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
114
122
  case "failed":
115
123
  return "Needs operator intervention";
@@ -138,6 +146,10 @@ function statusHeadline(issue, activeRunType) {
138
146
  case "escalated":
139
147
  return "Needs operator intervention";
140
148
  case "done":
149
+ if (issue.prNumber !== undefined && issue.prState === "merged")
150
+ return `Completed with merged PR #${issue.prNumber}`;
151
+ if (issue.prNumber !== undefined && isClosedPrState(issue.prState))
152
+ return `Completed without merging PR #${issue.prNumber}`;
141
153
  return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
142
154
  default:
143
155
  return humanize(issue.factoryState);
@@ -0,0 +1,49 @@
1
+ import { TERMINAL_STATES } from "./factory-state.js";
2
+ export function isOpenPrState(prState) {
3
+ return prState === undefined || prState === "open";
4
+ }
5
+ export function hasOpenPr(prNumber, prState) {
6
+ // Transitional compatibility: older rows may still have a tracked PR number
7
+ // before webhook/reconciliation has populated pr_state.
8
+ return prNumber !== undefined && isOpenPrState(prState);
9
+ }
10
+ export function isClosedPrState(prState) {
11
+ return prState === "closed";
12
+ }
13
+ export function isCompletedLinearState(currentLinearStateType, currentLinearState) {
14
+ return currentLinearStateType === "completed"
15
+ || currentLinearState?.trim().toLowerCase() === "done";
16
+ }
17
+ export function isIssueCompleted(issue) {
18
+ return issue.factoryState === "done" || isCompletedLinearState(issue.currentLinearStateType, issue.currentLinearState);
19
+ }
20
+ export function isIssueTerminal(issue) {
21
+ return issue.factoryState !== undefined && TERMINAL_STATES.has(issue.factoryState);
22
+ }
23
+ export function resolveClosedPrDisposition(issue) {
24
+ if (isIssueCompleted(issue))
25
+ return "done";
26
+ if (isIssueTerminal(issue))
27
+ return "terminal";
28
+ return "redelegate";
29
+ }
30
+ export function resolveClosedPrFactoryState(issue) {
31
+ const disposition = resolveClosedPrDisposition(issue);
32
+ if (disposition === "done")
33
+ return "done";
34
+ if (disposition === "terminal")
35
+ return issue.factoryState;
36
+ return "delegated";
37
+ }
38
+ export function buildClosedPrCleanupFields() {
39
+ return {
40
+ prReviewState: null,
41
+ prCheckStatus: null,
42
+ lastBlockingReviewHeadSha: null,
43
+ lastGitHubCiSnapshotHeadSha: null,
44
+ lastGitHubCiSnapshotGateCheckName: null,
45
+ lastGitHubCiSnapshotGateCheckStatus: null,
46
+ lastGitHubCiSnapshotJson: null,
47
+ lastGitHubCiSnapshotSettledAt: null,
48
+ };
49
+ }
@@ -1,4 +1,5 @@
1
1
  import { buildOperatorRetryEvent } from "./operator-retry-event.js";
2
+ import { hasOpenPr } from "./pr-state.js";
2
3
  export class ServiceIssueActions {
3
4
  db;
4
5
  codex;
@@ -107,21 +108,21 @@ export class ServiceIssueActions {
107
108
  }
108
109
  let runType = "implementation";
109
110
  let factoryState = "delegated";
110
- if (issue.prNumber && issue.lastGitHubFailureSource === "queue_eviction") {
111
+ if (hasOpenPr(issue.prNumber, issue.prState) && issue.lastGitHubFailureSource === "queue_eviction") {
111
112
  runType = "queue_repair";
112
113
  factoryState = "repairing_queue";
113
114
  }
114
- else if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
115
+ else if (hasOpenPr(issue.prNumber, issue.prState) && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
115
116
  runType = "ci_repair";
116
117
  factoryState = "repairing_ci";
117
118
  }
118
- else if (issue.prNumber && issue.prReviewState === "changes_requested") {
119
+ else if (hasOpenPr(issue.prNumber, issue.prState) && issue.prReviewState === "changes_requested") {
119
120
  runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
120
121
  ? "branch_upkeep"
121
122
  : "review_fix";
122
123
  factoryState = "changes_requested";
123
124
  }
124
- else if (issue.prNumber) {
125
+ else if (hasOpenPr(issue.prNumber, issue.prState)) {
125
126
  runType = "implementation";
126
127
  factoryState = "implementing";
127
128
  }
@@ -82,7 +82,7 @@ export class TrackedIssueListQuery {
82
82
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
83
83
  i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
84
84
  i.pending_run_type,
85
- i.pr_number, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
85
+ i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
86
86
  i.last_github_ci_snapshot_json,
87
87
  i.last_github_failure_source,
88
88
  i.last_github_failure_head_sha,
@@ -180,6 +180,7 @@ export class TrackedIssueListQuery {
180
180
  factoryState: String(row.factory_state ?? "delegated"),
181
181
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
182
182
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
183
+ ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
183
184
  ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
184
185
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
185
186
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
@@ -242,6 +243,7 @@ export class TrackedIssueListQuery {
242
243
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
243
244
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
244
245
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
246
+ ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
245
247
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
246
248
  ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
247
249
  ...(prChecksSummary ? { prChecksSummary } : {}),
@@ -15,6 +15,7 @@ export function buildTrackedIssueRecord(params) {
15
15
  factoryState: params.issue.factoryState,
16
16
  pendingRunType: params.issue.pendingRunType,
17
17
  prNumber: params.issue.prNumber,
18
+ prState: params.issue.prState,
18
19
  prHeadSha: params.issue.prHeadSha,
19
20
  prReviewState: params.issue.prReviewState,
20
21
  prCheckStatus: params.issue.prCheckStatus,
@@ -46,6 +47,10 @@ export function buildTrackedIssueRecord(params) {
46
47
  ...(params.issue.currentLinearState ? { currentLinearState: params.issue.currentLinearState } : {}),
47
48
  ...(params.session?.sessionState ? { sessionState: params.session.sessionState } : {}),
48
49
  factoryState: params.issue.factoryState,
50
+ ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
51
+ ...(params.issue.prState ? { prState: params.issue.prState } : {}),
52
+ ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
53
+ ...(params.issue.prCheckStatus ? { prCheckStatus: params.issue.prCheckStatus } : {}),
49
54
  blockedByCount: unresolvedBlockedBy.length,
50
55
  blockedByKeys,
51
56
  readyForExecution: isIssueSessionReadyForExecution({
@@ -1,3 +1,4 @@
1
+ import { hasOpenPr } from "./pr-state.js";
1
2
  export const PATCHRELAY_WAITING_REASONS = {
2
3
  activeWork: "PatchRelay is actively working",
3
4
  finalizingPublishedPr: "PatchRelay is finalizing a published PR",
@@ -14,7 +15,7 @@ export const PATCHRELAY_WAITING_REASONS = {
14
15
  };
15
16
  export function derivePatchRelayWaitingReason(params) {
16
17
  if (params.activeRunType) {
17
- if (params.prNumber !== undefined && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
18
+ if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
18
19
  return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
19
20
  }
20
21
  if (params.factoryState === "done") {
@@ -66,7 +67,7 @@ export function derivePatchRelayWaitingReason(params) {
66
67
  if (params.prReviewState === "approved") {
67
68
  return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
68
69
  }
69
- if (params.prNumber !== undefined) {
70
+ if (hasOpenPr(params.prNumber, params.prState)) {
70
71
  return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
71
72
  }
72
73
  if (params.pendingRunType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {