patchrelay 0.77.0 → 0.78.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.77.0",
4
- "commit": "618168f38a38",
5
- "builtAt": "2026-06-10T04:00:01.424Z"
3
+ "version": "0.78.1",
4
+ "commit": "6907b2e338b5",
5
+ "builtAt": "2026-06-10T20:42:13.572Z"
6
6
  }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Rows older than this that are still `pending` were abandoned by a crash or
3
+ * restart mid-processing. They are never replayed — recovery is re-derivation
4
+ * from GitHub/Linear via reconciliation — so the startup sweep marks them
5
+ * `abandoned`, which makes them archiveable like any other terminal status.
6
+ */
7
+ export const ABANDONED_PENDING_WEBHOOK_AGE_MS = 15 * 60 * 1000;
1
8
  export class WebhookEventStore {
2
9
  connection;
3
10
  constructor(connection) {
@@ -39,6 +46,21 @@ export class WebhookEventStore {
39
46
  markWebhookProcessed(id, status) {
40
47
  this.connection.prepare("UPDATE webhook_events SET processing_status = ? WHERE id = ?").run(status, id);
41
48
  }
49
+ /**
50
+ * Startup maintenance (core simplification plan, phase C2): mark rows stuck
51
+ * at `pending` since before the cutoff as `abandoned` so the retention pass
52
+ * can archive them. Returns the number of rows marked — each one is a
53
+ * crash-interrupted processing attempt worth surfacing to the operator.
54
+ */
55
+ markAbandonedPendingEventsBefore(cutoffIso) {
56
+ const result = this.connection.prepare(`
57
+ UPDATE webhook_events
58
+ SET processing_status = 'abandoned'
59
+ WHERE processing_status = 'pending'
60
+ AND received_at < ?
61
+ `).run(cutoffIso);
62
+ return Number(result.changes ?? 0);
63
+ }
42
64
  assignWebhookProject(id, projectId) {
43
65
  this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
44
66
  }
@@ -21,3 +21,43 @@ export const CLEARED_FAILURE_PROVENANCE = {
21
21
  lastAttemptedFailureSignature: null,
22
22
  lastAttemptedFailureAt: null,
23
23
  };
24
+ /**
25
+ * The single rule for clearing failure provenance (core simplification plan,
26
+ * phase C1): provenance may be cleared only when the observed evidence is
27
+ * NEWER than the recorded failure —
28
+ *
29
+ * 1. the PR merged or closed (nothing left to repair), or
30
+ * 2. the PR's current head differs from `lastGitHubFailureHeadSha` (the
31
+ * failing commit was superseded), or
32
+ * 3. the same kind of check that recorded the failure succeeded on the
33
+ * recorded failure head (the failure was actually fixed):
34
+ * - `queue_eviction` failures require the eviction check itself to
35
+ * succeed — a green *branch* gate proves nothing about integration
36
+ * with main (the swallowed-repair bug), while
37
+ * - `branch_ci` (and unclassified) failures are cleared by a green gate
38
+ * or a green eviction check on the failure head.
39
+ *
40
+ * A poll that merely "looks green" on the same head never clears a queue
41
+ * incident, and a stale check event for an unrelated head never clears
42
+ * anything.
43
+ */
44
+ export function mayClearFailureProvenance(current, observed) {
45
+ if (observed.prState === "merged" || observed.prState === "closed") {
46
+ return true;
47
+ }
48
+ const failureHeadSha = current.lastGitHubFailureHeadSha;
49
+ if (!failureHeadSha) {
50
+ // Nothing concrete recorded to preserve — clearing is harmless.
51
+ return true;
52
+ }
53
+ if (observed.headSha && observed.headIsCurrentTruth && observed.headSha !== failureHeadSha) {
54
+ return true;
55
+ }
56
+ if (observed.headSha === failureHeadSha) {
57
+ if (current.lastGitHubFailureSource === "queue_eviction") {
58
+ return observed.evictionCheckSucceeded === true;
59
+ }
60
+ return observed.gateCheckStatus === "success" || observed.evictionCheckSucceeded === true;
61
+ }
62
+ return false;
63
+ }
@@ -1,3 +1,4 @@
1
+ import { isTerminalRunStatus } from "./run-settlement.js";
1
2
  function isPatchRelayBot(login) {
2
3
  return login === "patchrelay[bot]" || login === "app/patchrelay";
3
4
  }
@@ -7,6 +8,25 @@ function parseRepo(repoFullName) {
7
8
  return undefined;
8
9
  return { owner, repo };
9
10
  }
11
+ /**
12
+ * Late-publication guard (core simplification plan, phase C3).
13
+ *
14
+ * Detects a PatchRelay-authored `pr_opened` for an issue with no recorded PR
15
+ * while the latest implementation run is already settled — settleRun owns
16
+ * settlement, so a terminal run can no longer claim this PR as its own
17
+ * publication. Every such PR gets an operator-feed alert.
18
+ *
19
+ * Autonomous action is limited to one case: a run with status `released`,
20
+ * which PatchRelay itself stopped before publication (issue blocked,
21
+ * undelegated, or superseded mid-implementation) — its PR is unwanted by
22
+ * construction and is auto-closed. For any other settled status (`completed`,
23
+ * `failed`, `superseded`) the run may have legitimately published right at
24
+ * the end (the webhook can race settlement), so the PR is linked normally
25
+ * and the operator decides.
26
+ *
27
+ * Returns `true` when the PR was suppressed (closed) and the webhook should
28
+ * not be projected onto the issue.
29
+ */
10
30
  export async function maybeCloseLatePublishedImplementationPr(params) {
11
31
  const { db, logger, feed, issue, event, fetchImpl } = params;
12
32
  if (event.triggerEvent !== "pr_opened")
@@ -20,8 +40,32 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
20
40
  const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
21
41
  if (!latestRun || latestRun.runType !== "implementation")
22
42
  return false;
23
- if (latestRun.status === "running" || latestRun.status === "completed")
43
+ // A non-terminal run (queued/running) is still allowed to publish — this
44
+ // is the normal mid-run PR creation path.
45
+ if (!isTerminalRunStatus(latestRun.status))
24
46
  return false;
47
+ // Detection: the implementation run is settled, yet a bot PR just arrived.
48
+ logger.warn({
49
+ issueKey: issue.issueKey,
50
+ prNumber: event.prNumber,
51
+ latestRunId: latestRun.id,
52
+ latestRunStatus: latestRun.status,
53
+ }, "Late PatchRelay PR detected after the implementation run was settled");
54
+ feed?.publish({
55
+ level: "warn",
56
+ kind: "github",
57
+ issueKey: issue.issueKey,
58
+ projectId: issue.projectId,
59
+ stage: issue.factoryState,
60
+ status: "late_pr_detected",
61
+ summary: `Detected late PR #${event.prNumber} from a settled implementation run (${latestRun.status})`,
62
+ detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
63
+ });
64
+ if (latestRun.status !== "released") {
65
+ // The run may have published legitimately just before settling; link the
66
+ // PR and leave the decision to the operator.
67
+ return false;
68
+ }
25
69
  const repo = parseRepo(event.repoFullName);
26
70
  const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
27
71
  if (!repo || !token) {
@@ -30,17 +74,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
30
74
  prNumber: event.prNumber,
31
75
  latestRunId: latestRun.id,
32
76
  latestRunStatus: latestRun.status,
33
- }, "Late PatchRelay PR was detected after the implementation run had already stopped, but PatchRelay could not auto-close it");
34
- feed?.publish({
35
- level: "warn",
36
- kind: "github",
37
- issueKey: issue.issueKey,
38
- projectId: issue.projectId,
39
- stage: issue.factoryState,
40
- status: "late_pr_detected",
41
- summary: `Detected late PR #${event.prNumber} from an inactive implementation run`,
42
- detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
43
- });
77
+ }, "Late PatchRelay PR from a released implementation run could not be auto-closed (missing repo or token)");
44
78
  return false;
45
79
  }
46
80
  const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/pulls/${event.prNumber}`, {
@@ -61,7 +95,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
61
95
  status: response.status,
62
96
  latestRunId: latestRun.id,
63
97
  latestRunStatus: latestRun.status,
64
- }, "Failed to auto-close late PatchRelay PR from an inactive implementation run");
98
+ }, "Failed to auto-close late PatchRelay PR from a released implementation run");
65
99
  feed?.publish({
66
100
  level: "warn",
67
101
  kind: "github",
@@ -79,7 +113,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
79
113
  prNumber: event.prNumber,
80
114
  latestRunId: latestRun.id,
81
115
  latestRunStatus: latestRun.status,
82
- }, "Auto-closed late PatchRelay PR from an inactive implementation run");
116
+ }, "Auto-closed late PatchRelay PR from a released implementation run");
83
117
  feed?.publish({
84
118
  level: "warn",
85
119
  kind: "github",
@@ -87,7 +121,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
87
121
  projectId: issue.projectId,
88
122
  stage: issue.factoryState,
89
123
  status: "late_pr_closed",
90
- summary: `Auto-closed late PR #${event.prNumber} from an inactive implementation run`,
124
+ summary: `Auto-closed late PR #${event.prNumber} from a released implementation run`,
91
125
  detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
92
126
  });
93
127
  return true;
@@ -1,5 +1,6 @@
1
- import { resolveFactoryStateFromGitHub } from "./factory-state.js";
1
+ import { mayClearFailureProvenance } from "./failure-provenance.js";
2
2
  import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
3
+ import { deriveFactoryStateFromPrFacts } from "./pr-facts-derivation.js";
3
4
  const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
4
5
  /**
5
6
  * GitHub sends both check_run and check_suite completion events.
@@ -61,28 +62,38 @@ export function isSettledBranchFailure(db, issue, event, project) {
61
62
  return false;
62
63
  return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
63
64
  }
65
+ /**
66
+ * Webhook adapter over {@link mayClearFailureProvenance} — translates a
67
+ * normalized GitHub event into the evidence object the shared rule expects.
68
+ * Check events can arrive out of order, so their head SHA only clears
69
+ * provenance when the success covers the recorded failure head; a
70
+ * `pr_synchronize` carries the freshly pushed head, which IS current truth.
71
+ */
64
72
  export function canClearFailureProvenance(issue, event, project) {
65
- if (event.triggerEvent !== "check_passed")
73
+ if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
66
74
  return true;
67
- if (isQueueEvictionFailure(issue, event, project)) {
68
- return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
69
75
  }
70
- if (!isGateCheckEvent(event, project)) {
76
+ if (event.triggerEvent === "pr_synchronize") {
77
+ return mayClearFailureProvenance(issue, {
78
+ headSha: event.headSha,
79
+ headIsCurrentTruth: true,
80
+ });
81
+ }
82
+ if (event.triggerEvent !== "check_passed") {
71
83
  return true;
72
84
  }
73
- if (isStaleGateEvent(issue, event)) {
74
- return false;
85
+ if (isQueueEvictionFailure(issue, event, project)) {
86
+ return mayClearFailureProvenance(issue, {
87
+ headSha: event.headSha,
88
+ evictionCheckSucceeded: true,
89
+ });
75
90
  }
76
- return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
91
+ return mayClearFailureProvenance(issue, {
92
+ headSha: event.headSha,
93
+ gateCheckStatus: "success",
94
+ });
77
95
  }
78
96
  export function resolveGitHubFactoryStateForEvent(issue, event, project, activeRun) {
79
- if (event.triggerEvent === "pr_closed") {
80
- return undefined;
81
- }
82
- const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
83
- && (event.prState === "open" || event.prNumber !== undefined)
84
- ? "pr_open"
85
- : issue.factoryState;
86
97
  // Classify check_failed events so the rule table can route them.
87
98
  // The duplicate short-circuit that lived here before is gone — the
88
99
  // table now handles queue_eviction via failureSource (plan §4.3).
@@ -92,19 +103,19 @@ export function resolveGitHubFactoryStateForEvent(issue, event, project, activeR
92
103
  const approvalHeadSha = event.triggerEvent === "review_approved"
93
104
  ? (event.reviewCommitId ?? event.headSha)
94
105
  : undefined;
95
- const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
106
+ return deriveFactoryStateFromPrFacts({
107
+ source: "webhook",
108
+ triggerEvent: event.triggerEvent,
109
+ prState: event.prState,
110
+ prNumber: event.prNumber,
111
+ headSha: event.headSha,
112
+ failureSource,
113
+ ...(approvalHeadSha ? { approvalHeadSha } : {}),
114
+ }, {
115
+ factoryState: issue.factoryState,
96
116
  prReviewState: issue.prReviewState,
97
117
  activeRunId: issue.activeRunId,
98
- failureSource,
99
118
  ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
100
119
  ...(activeRun?.sourceHeadSha ? { activeRunSourceHeadSha: activeRun.sourceHeadSha } : {}),
101
- ...(approvalHeadSha ? { approvalHeadSha } : {}),
102
120
  });
103
- if (resolved !== undefined) {
104
- return resolved;
105
- }
106
- if (effectiveCurrentState !== issue.factoryState) {
107
- return effectiveCurrentState;
108
- }
109
- return undefined;
110
121
  }
@@ -1,3 +1,8 @@
1
+ const MAX_CACHED_FILE_SETS = 512;
2
+ export function createSequenceBackstopCaches() {
3
+ return { alertedPrPairs: new Set(), changedFilesByHead: new Map() };
4
+ }
5
+ const processCaches = createSequenceBackstopCaches();
1
6
  // Plan §8.2: backstop for missed sequence-checks. When a PR is
2
7
  // opened and its changed-file set overlaps with another in-flight
3
8
  // PR's, surface an operator event so the agent can be re-prompted
@@ -7,6 +12,7 @@
7
12
  export async function maybeRunSequenceBackstop(params) {
8
13
  const { db, logger, feed, event } = params;
9
14
  const fetchImpl = params.fetchImpl ?? fetch;
15
+ const caches = params.caches ?? processCaches;
10
16
  if (event.triggerEvent !== "pr_opened")
11
17
  return;
12
18
  if (!event.repoFullName || event.prNumber === undefined)
@@ -17,7 +23,15 @@ export async function maybeRunSequenceBackstop(params) {
17
23
  const [owner, repo] = event.repoFullName.split("/", 2);
18
24
  if (!owner || !repo)
19
25
  return;
20
- const newPrFiles = await listChangedFiles(fetchImpl, token, owner, repo, event.prNumber).catch(() => undefined);
26
+ const newPrFiles = await listChangedFilesCached({
27
+ caches,
28
+ fetchImpl,
29
+ token,
30
+ owner,
31
+ repo,
32
+ prNumber: event.prNumber,
33
+ headSha: event.headSha,
34
+ });
21
35
  if (!newPrFiles || newPrFiles.size === 0)
22
36
  return;
23
37
  const candidates = db.issues
@@ -28,12 +42,24 @@ export async function maybeRunSequenceBackstop(params) {
28
42
  && issue.branchName !== undefined
29
43
  && issue.branchName !== event.branchName);
30
44
  for (const candidate of candidates) {
31
- const candidateFiles = await listChangedFiles(fetchImpl, token, owner, repo, candidate.prNumber).catch(() => undefined);
45
+ const pairKey = `${owner}/${repo}#${event.prNumber}->#${candidate.prNumber}`;
46
+ if (caches.alertedPrPairs.has(pairKey))
47
+ continue;
48
+ const candidateFiles = await listChangedFilesCached({
49
+ caches,
50
+ fetchImpl,
51
+ token,
52
+ owner,
53
+ repo,
54
+ prNumber: candidate.prNumber,
55
+ headSha: candidate.prHeadSha,
56
+ });
32
57
  if (!candidateFiles)
33
58
  continue;
34
59
  const overlap = intersect(newPrFiles, candidateFiles);
35
60
  if (overlap.length === 0)
36
61
  continue;
62
+ caches.alertedPrPairs.add(pairKey);
37
63
  logger.info({
38
64
  event: "sequence_backstop_overlap_detected",
39
65
  prNumber: event.prNumber,
@@ -53,6 +79,23 @@ export async function maybeRunSequenceBackstop(params) {
53
79
  return;
54
80
  }
55
81
  }
82
+ async function listChangedFilesCached(params) {
83
+ const { caches } = params;
84
+ const cacheKey = params.headSha ? `${params.owner}/${params.repo}@${params.headSha}` : undefined;
85
+ if (cacheKey) {
86
+ const cached = caches.changedFilesByHead.get(cacheKey);
87
+ if (cached)
88
+ return cached;
89
+ }
90
+ const files = await listChangedFiles(params.fetchImpl, params.token, params.owner, params.repo, params.prNumber).catch(() => undefined);
91
+ if (cacheKey && files) {
92
+ if (caches.changedFilesByHead.size >= MAX_CACHED_FILE_SETS) {
93
+ caches.changedFilesByHead.clear();
94
+ }
95
+ caches.changedFilesByHead.set(cacheKey, files);
96
+ }
97
+ return files;
98
+ }
56
99
  async function listChangedFiles(fetchImpl, token, owner, repo, prNumber) {
57
100
  const result = new Set();
58
101
  let page = 1;
@@ -123,6 +123,9 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
123
123
  }
124
124
  const freshIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
125
125
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
126
+ // A push always resets the repair budgets and the CI snapshot for the new
127
+ // head; failure provenance is only cleared when the pushed head actually
128
+ // supersedes the recorded failure (mayClearFailureProvenance — phase C1).
126
129
  deps.db.issueSessions.commitIssueState({
127
130
  writer: WRITER,
128
131
  update: {
@@ -130,22 +133,12 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
130
133
  linearIssueId: issue.linearIssueId,
131
134
  ciRepairAttempts: 0,
132
135
  queueRepairAttempts: 0,
133
- lastGitHubFailureSource: null,
134
- lastGitHubFailureHeadSha: null,
135
- lastGitHubFailureSignature: null,
136
- lastGitHubFailureCheckName: null,
137
- lastGitHubFailureCheckUrl: null,
138
- lastGitHubFailureContextJson: null,
139
- lastGitHubFailureAt: null,
136
+ ...(canClearFailureProvenance(freshIssue, event, project) ? CLEARED_FAILURE_PROVENANCE : {}),
140
137
  lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
141
138
  lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
142
139
  lastGitHubCiSnapshotGateCheckStatus: "pending",
143
140
  lastGitHubCiSnapshotJson: null,
144
141
  lastGitHubCiSnapshotSettledAt: null,
145
- lastQueueIncidentJson: null,
146
- lastAttemptedFailureHeadSha: null,
147
- lastAttemptedFailureSignature: null,
148
- lastAttemptedFailureAt: null,
149
142
  },
150
143
  });
151
144
  }
@@ -370,7 +363,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
370
363
  if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionFailure(issue, event, project) || isGateCheckEvent(event, project)))
371
364
  || event.triggerEvent === "pr_synchronize"
372
365
  || event.triggerEvent === "pr_merged") {
373
- if (event.triggerEvent === "check_passed" && !canClearFailureProvenance(issue, event, project)) {
366
+ if (!canClearFailureProvenance(issue, event, project)) {
374
367
  return;
375
368
  }
376
369
  deps.db.issueSessions.commitIssueState({
@@ -1,5 +1,6 @@
1
1
  import { TERMINAL_STATES } from "./factory-state.js";
2
- import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
2
+ import { CLEARED_FAILURE_PROVENANCE, mayClearFailureProvenance, } from "./failure-provenance.js";
3
+ import { deriveFactoryStateFromPrFacts } from "./pr-facts-derivation.js";
3
4
  import { DEPLOY_WATCH_TIMEOUT_MS, evaluateDeploy, isDeployTrackingEnabled, } from "./post-merge-deploy.js";
4
5
  import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
5
6
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
@@ -73,11 +74,13 @@ export class IdleIssueReconciler {
73
74
  if (issue.prNumber) {
74
75
  await this.reconcileFromGitHub(issue);
75
76
  }
76
- else if (issue.factoryState !== "awaiting_queue") {
77
- this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
78
- }
79
- else if (hasFailureProvenance(issue)) {
80
- this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
77
+ else {
78
+ // No PR to poll means no fresh GitHub evidence — provenance may
79
+ // only be cleared when nothing concrete is recorded to preserve.
80
+ const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, {});
81
+ if (issue.factoryState !== "awaiting_queue" || clear) {
82
+ this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : {});
83
+ }
81
84
  }
82
85
  continue;
83
86
  }
@@ -526,8 +529,12 @@ export class IdleIssueReconciler {
526
529
  if (!snapshot.ok) {
527
530
  this.logger.debug({ issueKey: issue.issueKey, error: snapshot.error.message }, "Failed to query GitHub PR state during reconciliation");
528
531
  if (issue.prReviewState === "approved") {
529
- if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
530
- this.advanceIdleIssue(issue, "awaiting_queue", hasFailureProvenance(issue) ? { clearFailureProvenance: true } : {});
532
+ // The poll failed, so there is no fresh evidence: never clear
533
+ // recorded failure provenance on this path (a green-looking local
534
+ // row must not swallow a pending repair).
535
+ const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, {});
536
+ if (issue.factoryState !== "awaiting_queue" || clear) {
537
+ this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : {});
531
538
  }
532
539
  }
533
540
  return;
@@ -537,6 +544,34 @@ export class IdleIssueReconciler {
537
544
  const previousHeadSha = issue.prHeadSha;
538
545
  const gateCheckNames = getGateCheckNames(project);
539
546
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
547
+ const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
548
+ const prState = pr.state === "MERGED" ? "merged" : pr.state === "CLOSED" ? "closed" : "open";
549
+ // Normalized level observation shared with the webhook path (plan §C1):
550
+ // every factory-state decision below goes through
551
+ // deriveFactoryStateFromPrFacts so both ingestion paths derive the same
552
+ // state from the same facts.
553
+ const observed = {
554
+ source: "poll",
555
+ prState,
556
+ prNumber,
557
+ ...(pr.reviewDecision ? { reviewDecision: pr.reviewDecision } : {}),
558
+ ...(gateCheckStatus ? { gateCheckStatus } : {}),
559
+ ...(pr.headRefOid ? { headSha: pr.headRefOid } : {}),
560
+ headAdvanced,
561
+ ...(prState === "closed" ? { closedPrDisposition: resolveClosedPrDisposition(issue) } : {}),
562
+ };
563
+ const currentFacts = (record) => ({
564
+ factoryState: record.factoryState,
565
+ prReviewState: record.prReviewState,
566
+ activeRunId: record.activeRunId,
567
+ });
568
+ // Evidence for the provenance rule: the polled head is current truth.
569
+ const provenanceEvidence = {
570
+ prState,
571
+ ...(pr.headRefOid ? { headSha: pr.headRefOid } : {}),
572
+ headIsCurrentTruth: true,
573
+ ...(gateCheckStatus ? { gateCheckStatus } : {}),
574
+ };
540
575
  const factsCommit = this.db.issueSessions.commitIssueState({
541
576
  writer: WRITER,
542
577
  update: {
@@ -560,7 +595,9 @@ export class IdleIssueReconciler {
560
595
  return;
561
596
  }
562
597
  if (pr.state === "CLOSED") {
563
- const closedPrDisposition = resolveClosedPrDisposition(issue);
598
+ // State decision shared with the webhook path; a closed PR is always
599
+ // newer evidence than any recorded failure, so clearing is allowed.
600
+ const closedState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
564
601
  const closedCommit = this.db.issueSessions.commitIssueState({
565
602
  writer: WRITER,
566
603
  update: {
@@ -573,12 +610,12 @@ export class IdleIssueReconciler {
573
610
  if (closedCommit.outcome === "applied") {
574
611
  issue = closedCommit.issue;
575
612
  }
576
- if (closedPrDisposition === "done") {
613
+ if (closedState === "done") {
577
614
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
578
615
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
579
616
  return;
580
617
  }
581
- if (closedPrDisposition === "terminal") {
618
+ if (closedState === undefined) {
582
619
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
583
620
  return;
584
621
  }
@@ -595,9 +632,9 @@ export class IdleIssueReconciler {
595
632
  }
596
633
  return;
597
634
  }
598
- const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
599
- if (issue.factoryState !== "awaiting_input") {
600
- const terminalRecoveryState = this.deriveTerminalRecoveryState(issue, pr.reviewDecision, gateCheckStatus, headAdvanced);
635
+ if (issue.factoryState !== "awaiting_input"
636
+ && (issue.factoryState === "escalated" || issue.factoryState === "failed")) {
637
+ const terminalRecoveryState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
601
638
  if (terminalRecoveryState) {
602
639
  this.logger.info({
603
640
  issueKey: issue.issueKey,
@@ -608,7 +645,8 @@ export class IdleIssueReconciler {
608
645
  reviewDecision: pr.reviewDecision,
609
646
  headAdvanced,
610
647
  }, "Reconciliation: recovered terminal issue from newer GitHub truth");
611
- this.advanceIdleIssue(issue, terminalRecoveryState, { clearFailureProvenance: true });
648
+ const clear = mayClearFailureProvenance(issue, provenanceEvidence);
649
+ this.advanceIdleIssue(issue, terminalRecoveryState, clear ? { clearFailureProvenance: true } : {});
612
650
  return;
613
651
  }
614
652
  }
@@ -669,7 +707,7 @@ export class IdleIssueReconciler {
669
707
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
670
708
  pendingRunType: reactiveIntent.runType,
671
709
  ...(pendingRunContext ? { pendingRunContext } : {}),
672
- clearFailureProvenance: true,
710
+ ...(mayClearFailureProvenance(issue, provenanceEvidence) ? { clearFailureProvenance: true } : {}),
673
711
  });
674
712
  return;
675
713
  }
@@ -684,7 +722,7 @@ export class IdleIssueReconciler {
684
722
  }, "Reconciliation: re-queued requested-changes follow-up from GitHub truth");
685
723
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
686
724
  pendingRunType: reactiveIntent.runType,
687
- clearFailureProvenance: true,
725
+ ...(mayClearFailureProvenance(issue, provenanceEvidence) ? { clearFailureProvenance: true } : {}),
688
726
  });
689
727
  this.feed?.publish({
690
728
  level: "warn",
@@ -744,9 +782,14 @@ export class IdleIssueReconciler {
744
782
  prReviewState: "approved",
745
783
  },
746
784
  });
747
- if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
748
- const options = hasFailureProvenance(issue) ? { clearFailureProvenance: true } : undefined;
749
- this.advanceIdleIssue(issue, "awaiting_queue", options);
785
+ const approvedState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
786
+ if (approvedState === "awaiting_queue") {
787
+ // Provenance survives unless the polled evidence is newer than the
788
+ // recorded failure (head advanced, gate green on the failure head).
789
+ const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, provenanceEvidence);
790
+ if (issue.factoryState !== "awaiting_queue" || clear) {
791
+ this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : undefined);
792
+ }
750
793
  }
751
794
  return;
752
795
  }
@@ -755,22 +798,4 @@ export class IdleIssueReconciler {
755
798
  }
756
799
  }
757
800
  }
758
- deriveTerminalRecoveryState(issue, reviewDecision, gateCheckStatus, headAdvanced) {
759
- if (issue.factoryState !== "escalated" && issue.factoryState !== "failed") {
760
- return undefined;
761
- }
762
- if (isReviewDecisionApproved(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
763
- return "awaiting_queue";
764
- }
765
- if (gateCheckStatus === "pending") {
766
- return "pr_open";
767
- }
768
- if (headAdvanced && !isFailingCheckStatus(gateCheckStatus)) {
769
- return "pr_open";
770
- }
771
- if (isReviewDecisionReviewRequired(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
772
- return "pr_open";
773
- }
774
- return undefined;
775
- }
776
801
  }
@@ -0,0 +1,81 @@
1
+ import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
+ import { isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
3
+ /**
4
+ * Pure factory-state derivation shared by the webhook projector and the idle
5
+ * reconciler. Returns the state the issue should move to, or `undefined`
6
+ * when the observation is a no-op for the current state.
7
+ */
8
+ export function deriveFactoryStateFromPrFacts(observed, current) {
9
+ if (observed.triggerEvent !== undefined) {
10
+ return deriveFromTriggerEvent(observed.triggerEvent, observed, current);
11
+ }
12
+ return deriveFromPolledLevel(observed, current);
13
+ }
14
+ // ── Delta observations (webhook trigger events) ─────────────────────
15
+ // The transition-rule table in factory-state.ts is the spec; this wrapper
16
+ // adds the awaiting_input/delegated lifting that the webhook path applies
17
+ // before consulting the table.
18
+ function deriveFromTriggerEvent(triggerEvent, observed, current) {
19
+ if (triggerEvent === "pr_closed") {
20
+ // The terminal-PR handler owns the closed-PR decision on the webhook path.
21
+ return undefined;
22
+ }
23
+ const effectiveCurrentState = (current.factoryState === "awaiting_input" || current.factoryState === "delegated")
24
+ && (observed.prState === "open" || observed.prNumber !== undefined)
25
+ ? "pr_open"
26
+ : current.factoryState;
27
+ const resolved = resolveFactoryStateFromGitHub(triggerEvent, effectiveCurrentState, {
28
+ prReviewState: current.prReviewState,
29
+ activeRunId: current.activeRunId,
30
+ failureSource: observed.failureSource,
31
+ ...(current.activeRunType ? { activeRunType: current.activeRunType } : {}),
32
+ ...(current.activeRunSourceHeadSha ? { activeRunSourceHeadSha: current.activeRunSourceHeadSha } : {}),
33
+ ...(observed.approvalHeadSha ? { approvalHeadSha: observed.approvalHeadSha } : {}),
34
+ });
35
+ if (resolved !== undefined) {
36
+ return resolved;
37
+ }
38
+ if (effectiveCurrentState !== current.factoryState) {
39
+ return effectiveCurrentState;
40
+ }
41
+ return undefined;
42
+ }
43
+ // ── Level observations (polled snapshot) ────────────────────────────
44
+ function deriveFromPolledLevel(observed, current) {
45
+ if (observed.prState === "closed") {
46
+ if (observed.closedPrDisposition === "done")
47
+ return "done";
48
+ if (observed.closedPrDisposition === "terminal")
49
+ return undefined;
50
+ return "delegated";
51
+ }
52
+ if (observed.prState === "merged") {
53
+ // Mirrors the pr_merged transition rule: with an active run the
54
+ // finalizer owns the completion; deploy tracking may map "done" to
55
+ // "deploying" at the call site.
56
+ return current.activeRunId === undefined ? "done" : undefined;
57
+ }
58
+ if (current.factoryState === "escalated" || current.factoryState === "failed") {
59
+ // Terminal recovery: newer GitHub truth reopens a stuck terminal issue.
60
+ // No fall-through to the generic approved rule — an escalated issue with
61
+ // a red gate stays escalated (the failure provenance keeps the repair
62
+ // routable; auto-reopening would swallow it).
63
+ if (isReviewDecisionApproved(observed.reviewDecision) && !isFailingCheckStatus(observed.gateCheckStatus)) {
64
+ return "awaiting_queue";
65
+ }
66
+ if (observed.gateCheckStatus === "pending") {
67
+ return "pr_open";
68
+ }
69
+ if (observed.headAdvanced && !isFailingCheckStatus(observed.gateCheckStatus)) {
70
+ return "pr_open";
71
+ }
72
+ if (isReviewDecisionReviewRequired(observed.reviewDecision) && !isFailingCheckStatus(observed.gateCheckStatus)) {
73
+ return "pr_open";
74
+ }
75
+ return undefined;
76
+ }
77
+ if (isReviewDecisionApproved(observed.reviewDecision)) {
78
+ return "awaiting_queue";
79
+ }
80
+ return undefined;
81
+ }
package/dist/service.js CHANGED
@@ -14,6 +14,7 @@ import { ServiceStartupRecovery } from "./service-startup-recovery.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
15
  import { WebhookHandler } from "./webhook-handler.js";
16
16
  import { acceptIncomingWebhook } from "./service-webhooks.js";
17
+ import { ABANDONED_PENDING_WEBHOOK_AGE_MS } from "./db/webhook-event-store.js";
17
18
  import { runWebhookEventRetention } from "./event-retention.js";
18
19
  import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
19
20
  import { AgentInputService } from "./agent-input-service.js";
@@ -103,6 +104,7 @@ export class PatchRelayService {
103
104
  }
104
105
  async start() {
105
106
  this.db.issueSessions.releaseExpiredIssueSessionLeases();
107
+ this.sweepAbandonedWebhookEvents();
106
108
  const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
107
109
  for (const repair of repairedInstallations) {
108
110
  this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
@@ -287,6 +289,26 @@ export class PatchRelayService {
287
289
  getReadiness() {
288
290
  return this.runtime.getReadiness();
289
291
  }
292
+ // Core simplification plan §C2: webhook_events is a dedupe + forensics log,
293
+ // not a replay queue. A row stuck at 'pending' means a crash or restart
294
+ // interrupted processing; the event will never be replayed (recovery is
295
+ // re-derivation from GitHub/Linear via reconciliation), so mark it
296
+ // 'abandoned' — making it archiveable — and surface the count to the
297
+ // operator, because every abandoned row is a crash worth seeing.
298
+ sweepAbandonedWebhookEvents() {
299
+ const cutoffIso = new Date(Date.now() - ABANDONED_PENDING_WEBHOOK_AGE_MS).toISOString();
300
+ const abandoned = this.db.webhookEvents.markAbandonedPendingEventsBefore(cutoffIso);
301
+ if (abandoned === 0)
302
+ return;
303
+ this.logger.warn({ abandoned, cutoffIso }, "Marked stale pending webhook events as abandoned at startup");
304
+ this.feed.publish({
305
+ level: "warn",
306
+ kind: "webhook",
307
+ status: "abandoned_events",
308
+ summary: `Startup: marked ${abandoned} stale pending webhook event(s) as abandoned`,
309
+ detail: "Processing was interrupted (crash/restart). State recovers via reconciliation; the rows stay archiveable for forensics.",
310
+ });
311
+ }
290
312
  scheduleEventRetention(delayMs = 24 * 60 * 60 * 1000) {
291
313
  if (this.eventRetentionTimer !== undefined) {
292
314
  clearTimeout(this.eventRetentionTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.77.0",
3
+ "version": "0.78.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -49,7 +49,7 @@
49
49
  "start": "node dist/index.js serve",
50
50
  "doctor": "node dist/index.js doctor",
51
51
  "restart": "node dist/index.js service restart",
52
- "deploy": "pnpm build && pnpm add -g . && node dist/index.js service restart",
52
+ "deploy": "pnpm pack --out /tmp/patchrelay-deploy.tgz && pnpm add -g /tmp/patchrelay-deploy.tgz && patchrelay service restart",
53
53
  "lint": "oxlint --ignore-path .gitignore .",
54
54
  "typecheck": "tsgo -p tsconfig.json --noEmit",
55
55
  "check": "pnpm typecheck",