patchrelay 0.75.2 → 0.76.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.
Files changed (40) hide show
  1. package/dist/agent-input-service.js +40 -26
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/data.js +3 -1
  4. package/dist/db/issue-session-store.js +44 -9
  5. package/dist/db/issue-store.js +31 -2
  6. package/dist/db/migrations.js +3 -0
  7. package/dist/factory-state.js +23 -0
  8. package/dist/github-webhook-reactive-run.js +15 -11
  9. package/dist/github-webhook-stack-coordination.js +8 -4
  10. package/dist/github-webhook-state-projector.js +204 -139
  11. package/dist/github-webhook-terminal-handler.js +37 -27
  12. package/dist/idle-reconciliation.js +122 -66
  13. package/dist/implementation-outcome-policy.js +5 -1
  14. package/dist/interrupted-run-recovery.js +46 -33
  15. package/dist/issue-session-projection-invalidator.js +9 -0
  16. package/dist/linear-agent-session-client.js +16 -8
  17. package/dist/linear-issue-projection.js +15 -11
  18. package/dist/linear-status-comment-sync.js +8 -4
  19. package/dist/linear-workflow-state-sync.js +9 -5
  20. package/dist/merged-linear-completion-reconciler.js +39 -17
  21. package/dist/no-pr-completion-check.js +51 -29
  22. package/dist/orchestration-parent-wake.js +15 -8
  23. package/dist/queue-health-monitor.js +17 -8
  24. package/dist/reactive-run-policy.js +5 -1
  25. package/dist/run-finalizer.js +61 -29
  26. package/dist/run-launcher.js +42 -12
  27. package/dist/run-notification-handler.js +19 -7
  28. package/dist/run-orchestrator.js +121 -18
  29. package/dist/run-reconciler.js +121 -50
  30. package/dist/run-recovery-service.js +70 -33
  31. package/dist/run-wake-planner.js +39 -29
  32. package/dist/service-issue-actions.js +45 -28
  33. package/dist/service-startup-recovery.js +61 -35
  34. package/dist/telemetry.js +9 -0
  35. package/dist/terminal-wake-reconciler.js +20 -3
  36. package/dist/webhooks/agent-session-handler.js +22 -12
  37. package/dist/webhooks/dependency-readiness-handler.js +17 -10
  38. package/dist/webhooks/desired-stage-recorder.js +32 -13
  39. package/dist/webhooks/issue-removal-handler.js +24 -13
  40. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { canClearFailureProvenance, deriveImmediatePrCheckStatus, getGateCheckNa
5
5
  import { buildGitHubQueueFailureContext, resolveGitHubBranchFailureContext, } from "./github-webhook-failure-context.js";
6
6
  import { emitGitHubLinearActivity, syncGitHubLinearSession } from "./github-linear-session-sync.js";
7
7
  import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
8
+ const WRITER = "github-webhook-state-projector";
8
9
  export async function projectGitHubWebhookState(deps, issue, event, project, linkedBy) {
9
10
  const failureContextResolver = deps.failureContextResolver ?? createGitHubFailureContextResolver();
10
11
  const ciSnapshotResolver = deps.ciSnapshotResolver ?? createGitHubCiSnapshotResolver();
@@ -15,26 +16,31 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
15
16
  // the field when a base ref reverts to the default (e.g. parent
16
17
  // landed and GitHub auto-retargeted) or when the PR closes.
17
18
  const parentPrBranch = computeParentPrBranchUpdate(event, project);
18
- deps.db.issues.upsertIssue({
19
- projectId: issue.projectId,
20
- linearIssueId: issue.linearIssueId,
21
- ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
22
- ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
23
- ...(event.prState !== undefined ? { prState: event.prState } : {}),
24
- ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
25
- ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
26
- ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
27
- ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
28
- ...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
29
- ...(parentPrBranch !== undefined ? { parentPrBranch } : {}),
30
- ...(event.reviewState === "changes_requested"
31
- ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
32
- : event.reviewState === "approved"
33
- ? { lastBlockingReviewHeadSha: null }
19
+ // Unconditional commit: every field below is a fact carried by the webhook
20
+ // payload itself, not derived from a prior read of the issue row.
21
+ deps.db.issueSessions.commitIssueState({
22
+ writer: WRITER,
23
+ update: {
24
+ projectId: issue.projectId,
25
+ linearIssueId: issue.linearIssueId,
26
+ ...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
27
+ ...(event.prUrl !== undefined ? { prUrl: event.prUrl } : {}),
28
+ ...(event.prState !== undefined ? { prState: event.prState } : {}),
29
+ ...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
30
+ ...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
31
+ ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
32
+ ...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
33
+ ...(linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
34
+ ...(parentPrBranch !== undefined ? { parentPrBranch } : {}),
35
+ ...(event.reviewState === "changes_requested"
36
+ ? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
37
+ : event.reviewState === "approved"
38
+ ? { lastBlockingReviewHeadSha: null }
39
+ : {}),
40
+ ...(event.triggerEvent === "pr_closed"
41
+ ? buildClosedPrCleanupFields()
34
42
  : {}),
35
- ...(event.triggerEvent === "pr_closed"
36
- ? buildClosedPrCleanupFields()
37
- : {}),
43
+ },
38
44
  });
39
45
  await updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver);
40
46
  await updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver);
@@ -51,66 +57,96 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
51
57
  }
52
58
  : undefined);
53
59
  if (newState && newState !== afterMetadata.factoryState) {
54
- deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
55
- projectId: issue.projectId,
56
- linearIssueId: issue.linearIssueId,
57
- factoryState: newState,
58
- });
59
- deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
60
- // Plan §4.4: when the transition fired *because* an approval
61
- // landed during a review_fix run on the same head (the
62
- // mid-run-approval rule), the run's premise is gone. Mark it
63
- // superseded and set the publication-suppression flag so the
64
- // finalizer cannot push a cosmetic patch-id-equivalent commit.
65
- maybeSupersedeActiveRun({
66
- db: deps.db,
67
- logger: deps.logger,
68
- feed: deps.feed,
69
- issue: afterMetadata,
70
- newState,
71
- event,
72
- activeRun,
73
- });
74
- const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
75
- void emitGitHubLinearActivity({
76
- linearProvider: deps.linearProvider,
77
- logger: deps.logger,
78
- feed: deps.feed,
79
- issue: transitionedIssue,
80
- newState,
81
- event,
82
- });
83
- void syncGitHubLinearSession({
84
- config: deps.config,
85
- linearProvider: deps.linearProvider,
86
- logger: deps.logger,
87
- issue: transitionedIssue,
60
+ const transitionCommit = deps.db.issueSessions.commitIssueState({
61
+ writer: WRITER,
62
+ expectedVersion: afterMetadata.version,
63
+ update: {
64
+ projectId: issue.projectId,
65
+ linearIssueId: issue.linearIssueId,
66
+ factoryState: newState,
67
+ },
68
+ // Conflict: another writer landed since `afterMetadata` was read.
69
+ // Re-resolve the transition against the fresh row so we never
70
+ // regress a state someone else just advanced.
71
+ onConflict: (current) => {
72
+ const recomputed = resolveGitHubFactoryStateForEvent(current, event, project, activeRun
73
+ ? {
74
+ ...(activeRun.runType ? { runType: activeRun.runType } : {}),
75
+ ...(activeRun.sourceHeadSha ? { sourceHeadSha: activeRun.sourceHeadSha } : {}),
76
+ }
77
+ : undefined);
78
+ if (!recomputed || recomputed === current.factoryState)
79
+ return undefined;
80
+ return {
81
+ projectId: issue.projectId,
82
+ linearIssueId: issue.linearIssueId,
83
+ factoryState: recomputed,
84
+ };
85
+ },
88
86
  });
87
+ const appliedState = transitionCommit.outcome === "applied"
88
+ ? transitionCommit.issue.factoryState
89
+ : undefined;
90
+ if (appliedState) {
91
+ deps.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: appliedState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
92
+ // Plan §4.4: when the transition fired *because* an approval
93
+ // landed during a review_fix run on the same head (the
94
+ // mid-run-approval rule), the run's premise is gone. Mark it
95
+ // superseded and set the publication-suppression flag so the
96
+ // finalizer cannot push a cosmetic patch-id-equivalent commit.
97
+ maybeSupersedeActiveRun({
98
+ db: deps.db,
99
+ logger: deps.logger,
100
+ feed: deps.feed,
101
+ issue: afterMetadata,
102
+ newState: appliedState,
103
+ event,
104
+ activeRun,
105
+ });
106
+ const transitionedIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
107
+ void emitGitHubLinearActivity({
108
+ linearProvider: deps.linearProvider,
109
+ logger: deps.logger,
110
+ feed: deps.feed,
111
+ issue: transitionedIssue,
112
+ newState: appliedState,
113
+ event,
114
+ });
115
+ void syncGitHubLinearSession({
116
+ config: deps.config,
117
+ linearProvider: deps.linearProvider,
118
+ logger: deps.logger,
119
+ issue: transitionedIssue,
120
+ });
121
+ }
89
122
  }
90
123
  }
91
124
  const freshIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
92
125
  if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
93
- deps.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
94
- projectId: issue.projectId,
95
- linearIssueId: issue.linearIssueId,
96
- ciRepairAttempts: 0,
97
- queueRepairAttempts: 0,
98
- lastGitHubFailureSource: null,
99
- lastGitHubFailureHeadSha: null,
100
- lastGitHubFailureSignature: null,
101
- lastGitHubFailureCheckName: null,
102
- lastGitHubFailureCheckUrl: null,
103
- lastGitHubFailureContextJson: null,
104
- lastGitHubFailureAt: null,
105
- lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
106
- lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
107
- lastGitHubCiSnapshotGateCheckStatus: "pending",
108
- lastGitHubCiSnapshotJson: null,
109
- lastGitHubCiSnapshotSettledAt: null,
110
- lastQueueIncidentJson: null,
111
- lastAttemptedFailureHeadSha: null,
112
- lastAttemptedFailureSignature: null,
113
- lastAttemptedFailureAt: null,
126
+ deps.db.issueSessions.commitIssueState({
127
+ writer: WRITER,
128
+ update: {
129
+ projectId: issue.projectId,
130
+ linearIssueId: issue.linearIssueId,
131
+ ciRepairAttempts: 0,
132
+ queueRepairAttempts: 0,
133
+ lastGitHubFailureSource: null,
134
+ lastGitHubFailureHeadSha: null,
135
+ lastGitHubFailureSignature: null,
136
+ lastGitHubFailureCheckName: null,
137
+ lastGitHubFailureCheckUrl: null,
138
+ lastGitHubFailureContextJson: null,
139
+ lastGitHubFailureAt: null,
140
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
141
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
142
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
143
+ lastGitHubCiSnapshotJson: null,
144
+ lastGitHubCiSnapshotSettledAt: null,
145
+ lastQueueIncidentJson: null,
146
+ lastAttemptedFailureHeadSha: null,
147
+ lastAttemptedFailureSignature: null,
148
+ lastAttemptedFailureAt: null,
149
+ },
114
150
  });
115
151
  }
116
152
  deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
@@ -181,27 +217,33 @@ function maybeSupersedeActiveRun(params) {
181
217
  }
182
218
  async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotResolver) {
183
219
  if (event.triggerEvent === "pr_merged") {
184
- deps.db.issues.upsertIssue({
185
- projectId: issue.projectId,
186
- linearIssueId: issue.linearIssueId,
187
- lastGitHubCiSnapshotHeadSha: null,
188
- lastGitHubCiSnapshotGateCheckName: null,
189
- lastGitHubCiSnapshotGateCheckStatus: null,
190
- lastGitHubCiSnapshotJson: null,
191
- lastGitHubCiSnapshotSettledAt: null,
220
+ deps.db.issueSessions.commitIssueState({
221
+ writer: WRITER,
222
+ update: {
223
+ projectId: issue.projectId,
224
+ linearIssueId: issue.linearIssueId,
225
+ lastGitHubCiSnapshotHeadSha: null,
226
+ lastGitHubCiSnapshotGateCheckName: null,
227
+ lastGitHubCiSnapshotGateCheckStatus: null,
228
+ lastGitHubCiSnapshotJson: null,
229
+ lastGitHubCiSnapshotSettledAt: null,
230
+ },
192
231
  });
193
232
  return;
194
233
  }
195
234
  if (event.triggerEvent === "pr_synchronize") {
196
- deps.db.issues.upsertIssue({
197
- projectId: issue.projectId,
198
- linearIssueId: issue.linearIssueId,
199
- prCheckStatus: "pending",
200
- lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
201
- lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
202
- lastGitHubCiSnapshotGateCheckStatus: "pending",
203
- lastGitHubCiSnapshotJson: null,
204
- lastGitHubCiSnapshotSettledAt: null,
235
+ deps.db.issueSessions.commitIssueState({
236
+ writer: WRITER,
237
+ update: {
238
+ projectId: issue.projectId,
239
+ linearIssueId: issue.linearIssueId,
240
+ prCheckStatus: "pending",
241
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
242
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
243
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
244
+ lastGitHubCiSnapshotJson: null,
245
+ lastGitHubCiSnapshotSettledAt: null,
246
+ },
205
247
  });
206
248
  return;
207
249
  }
@@ -216,32 +258,42 @@ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotRes
216
258
  if (isStaleGateEvent(issue, event))
217
259
  return;
218
260
  if (event.triggerEvent === "check_pending") {
219
- deps.db.issues.upsertIssue({
220
- projectId: issue.projectId,
221
- linearIssueId: issue.linearIssueId,
222
- prCheckStatus: "pending",
223
- lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
224
- lastGitHubCiSnapshotGateCheckName: event.checkName ?? getPrimaryGateCheckName(project),
225
- lastGitHubCiSnapshotGateCheckStatus: "pending",
226
- lastGitHubCiSnapshotJson: null,
227
- lastGitHubCiSnapshotSettledAt: null,
261
+ deps.db.issueSessions.commitIssueState({
262
+ writer: WRITER,
263
+ update: {
264
+ projectId: issue.projectId,
265
+ linearIssueId: issue.linearIssueId,
266
+ prCheckStatus: "pending",
267
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
268
+ lastGitHubCiSnapshotGateCheckName: event.checkName ?? getPrimaryGateCheckName(project),
269
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
270
+ lastGitHubCiSnapshotJson: null,
271
+ lastGitHubCiSnapshotSettledAt: null,
272
+ },
228
273
  });
229
274
  return;
230
275
  }
276
+ // Version read just before the async snapshot resolution: a conflict on the
277
+ // write below means another writer landed while we were calling GitHub.
278
+ const preResolveVersion = (deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue).version;
231
279
  const snapshot = await ciSnapshotResolver.resolve({
232
280
  repoFullName: project?.github?.repoFullName ?? event.repoFullName,
233
281
  event,
234
282
  gateCheckNames: getGateCheckNames(project),
235
283
  });
236
284
  if (!snapshot) {
237
- deps.db.issues.upsertIssue({
238
- projectId: issue.projectId,
239
- linearIssueId: issue.linearIssueId,
240
- lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
241
- lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
242
- lastGitHubCiSnapshotGateCheckStatus: "pending",
243
- lastGitHubCiSnapshotJson: null,
244
- lastGitHubCiSnapshotSettledAt: null,
285
+ deps.db.issueSessions.commitIssueState({
286
+ writer: WRITER,
287
+ expectedVersion: preResolveVersion,
288
+ update: {
289
+ projectId: issue.projectId,
290
+ linearIssueId: issue.linearIssueId,
291
+ lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
292
+ lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
293
+ lastGitHubCiSnapshotGateCheckStatus: "pending",
294
+ lastGitHubCiSnapshotJson: null,
295
+ lastGitHubCiSnapshotSettledAt: null,
296
+ },
245
297
  });
246
298
  deps.logger.warn({ issueKey: issue.issueKey, repoFullName: project?.github?.repoFullName ?? event.repoFullName, headSha: event.headSha }, "Could not resolve settled CI snapshot; waiting before CI repair");
247
299
  deps.feed?.publish({
@@ -255,15 +307,19 @@ async function updateGitHubCiSnapshot(deps, issue, event, project, ciSnapshotRes
255
307
  });
256
308
  return;
257
309
  }
258
- deps.db.issues.upsertIssue({
259
- projectId: issue.projectId,
260
- linearIssueId: issue.linearIssueId,
261
- prCheckStatus: snapshot.gateCheckStatus,
262
- lastGitHubCiSnapshotHeadSha: snapshot.headSha,
263
- lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? getPrimaryGateCheckName(project),
264
- lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
265
- lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
266
- lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
310
+ deps.db.issueSessions.commitIssueState({
311
+ writer: WRITER,
312
+ expectedVersion: preResolveVersion,
313
+ update: {
314
+ projectId: issue.projectId,
315
+ linearIssueId: issue.linearIssueId,
316
+ prCheckStatus: snapshot.gateCheckStatus,
317
+ lastGitHubCiSnapshotHeadSha: snapshot.headSha,
318
+ lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName ?? getPrimaryGateCheckName(project),
319
+ lastGitHubCiSnapshotGateCheckStatus: snapshot.gateCheckStatus,
320
+ lastGitHubCiSnapshotJson: JSON.stringify(snapshot),
321
+ lastGitHubCiSnapshotSettledAt: snapshot.settledAt ?? null,
322
+ },
267
323
  });
268
324
  }
269
325
  async function updateGitHubFailureProvenance(deps, issue, event, project, failureContextResolver) {
@@ -275,6 +331,8 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
275
331
  if (source === "branch_ci" && !isSettledBranchFailure(deps.db, issue, event, project)) {
276
332
  return;
277
333
  }
334
+ // Version read before the (possibly async) failure-context resolution.
335
+ const preResolveVersion = (deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue).version;
278
336
  const failureContext = source === "queue_eviction"
279
337
  ? buildGitHubQueueFailureContext(event, project, buildQueueRepairContextFromEvent(event))
280
338
  : await resolveGitHubBranchFailureContext({
@@ -284,24 +342,28 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
284
342
  project,
285
343
  failureContextResolver,
286
344
  });
287
- deps.db.issues.upsertIssue({
288
- projectId: issue.projectId,
289
- linearIssueId: issue.linearIssueId,
290
- lastGitHubFailureSource: source,
291
- lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
292
- lastGitHubFailureSignature: failureContext.failureSignature ?? null,
293
- lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
294
- lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
295
- lastGitHubFailureContextJson: JSON.stringify(failureContext),
296
- lastGitHubFailureAt: new Date().toISOString(),
297
- ...(source === "queue_eviction"
298
- ? {
299
- lastQueueSignalAt: new Date().toISOString(),
300
- lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
301
- }
302
- : {
303
- lastQueueIncidentJson: null,
304
- }),
345
+ deps.db.issueSessions.commitIssueState({
346
+ writer: WRITER,
347
+ expectedVersion: preResolveVersion,
348
+ update: {
349
+ projectId: issue.projectId,
350
+ linearIssueId: issue.linearIssueId,
351
+ lastGitHubFailureSource: source,
352
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? event.headSha ?? null,
353
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
354
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
355
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
356
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
357
+ lastGitHubFailureAt: new Date().toISOString(),
358
+ ...(source === "queue_eviction"
359
+ ? {
360
+ lastQueueSignalAt: new Date().toISOString(),
361
+ lastQueueIncidentJson: JSON.stringify(buildQueueRepairContextFromEvent(event)),
362
+ }
363
+ : {
364
+ lastQueueIncidentJson: null,
365
+ }),
366
+ },
305
367
  });
306
368
  return;
307
369
  }
@@ -311,10 +373,13 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
311
373
  if (event.triggerEvent === "check_passed" && !canClearFailureProvenance(issue, event, project)) {
312
374
  return;
313
375
  }
314
- deps.db.issues.upsertIssue({
315
- projectId: issue.projectId,
316
- linearIssueId: issue.linearIssueId,
317
- ...CLEARED_FAILURE_PROVENANCE,
376
+ deps.db.issueSessions.commitIssueState({
377
+ writer: WRITER,
378
+ update: {
379
+ projectId: issue.projectId,
380
+ linearIssueId: issue.linearIssueId,
381
+ ...CLEARED_FAILURE_PROVENANCE,
382
+ },
318
383
  });
319
384
  }
320
385
  }
@@ -3,6 +3,7 @@ import { resolvePostMergeFactoryState } from "./post-merge-deploy.js";
3
3
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
4
4
  import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
5
5
  import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
6
+ const WRITER = "github-webhook-terminal-handler";
6
7
  export async function handleGitHubTerminalPrEvent(params) {
7
8
  const { db, linearProvider, wakeDispatcher, logger, codex, issue, event, config } = params;
8
9
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
@@ -33,32 +34,35 @@ export async function handleGitHubTerminalPrEvent(params) {
33
34
  logger.warn({ issueKey: issue.issueKey, runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run after terminal PR event");
34
35
  }
35
36
  }
36
- const commitTerminalUpdate = () => {
37
- if (run) {
38
- db.runs.finishRun(run.id, {
39
- status: "released",
40
- failureReason: event.triggerEvent === "pr_merged"
41
- ? "Pull request merged during active run"
42
- : "Pull request closed during active run",
43
- });
44
- }
37
+ const buildTerminalUpdate = (row) => {
45
38
  const terminalFactoryState = event.triggerEvent === "pr_merged"
46
39
  ? postMergeState
47
- : resolveClosedPrFactoryState(issue);
48
- db.issues.upsertIssue({
40
+ : resolveClosedPrFactoryState(row);
41
+ return {
49
42
  projectId: issue.projectId,
50
43
  linearIssueId: issue.linearIssueId,
51
44
  activeRunId: null,
52
45
  factoryState: terminalFactoryState,
53
46
  ...(terminalFactoryState === "deploying" ? { deployStartedAt: new Date().toISOString() } : {}),
54
- });
47
+ };
55
48
  };
56
49
  const activeLease = db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
57
- if (activeLease) {
58
- db.issueSessions.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
59
- }
60
- else {
61
- db.transaction(commitTerminalUpdate);
50
+ const terminalCommit = db.issueSessions.commitIssueState({
51
+ writer: WRITER,
52
+ expectedVersion: issue.version,
53
+ ...(activeLease ? { lease: activeLease } : {}),
54
+ update: buildTerminalUpdate(issue),
55
+ // The terminal PR fact comes from GitHub; re-derive the closed-PR
56
+ // disposition from the fresh row instead of dropping the event.
57
+ onConflict: (current) => buildTerminalUpdate(current),
58
+ });
59
+ if (terminalCommit.outcome === "applied" && run) {
60
+ db.runs.finishRun(run.id, {
61
+ status: "released",
62
+ failureReason: event.triggerEvent === "pr_merged"
63
+ ? "Pull request merged during active run"
64
+ : "Pull request closed during active run",
65
+ });
62
66
  }
63
67
  db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
64
68
  const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
@@ -102,20 +106,26 @@ async function completeLinearIssueAfterMerge(params, issue) {
102
106
  }
103
107
  const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
104
108
  if (normalizedCurrent === targetState.trim().toLowerCase()) {
105
- params.db.issues.upsertIssue({
106
- projectId: issue.projectId,
107
- linearIssueId: issue.linearIssueId,
108
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
109
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
109
+ params.db.issueSessions.commitIssueState({
110
+ writer: WRITER,
111
+ update: {
112
+ projectId: issue.projectId,
113
+ linearIssueId: issue.linearIssueId,
114
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
115
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
116
+ },
110
117
  });
111
118
  return;
112
119
  }
113
120
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
114
- params.db.issues.upsertIssue({
115
- projectId: issue.projectId,
116
- linearIssueId: issue.linearIssueId,
117
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
118
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
121
+ params.db.issueSessions.commitIssueState({
122
+ writer: WRITER,
123
+ update: {
124
+ projectId: issue.projectId,
125
+ linearIssueId: issue.linearIssueId,
126
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
127
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
128
+ },
119
129
  });
120
130
  }
121
131
  catch (error) {