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
@@ -3,6 +3,7 @@ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
3
  import { isCompletedLinearState } from "./pr-state.js";
4
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
5
5
  import { replaceIssueDependenciesFromLinearIssue } from "./linear-issue-projection.js";
6
+ const WRITER = "merged-linear-completion-reconciler";
6
7
  const COMPLETION_RECONCILE_WINDOW_MS = 60 * 60 * 1000;
7
8
  const COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS = 60 * 60 * 1000;
8
9
  const COMPLETION_RECONCILE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
@@ -88,23 +89,41 @@ export class MergedLinearCompletionReconciler {
88
89
  return;
89
90
  }
90
91
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
91
- this.db.issues.upsertIssue({
92
- projectId: issue.projectId,
93
- linearIssueId: issue.linearIssueId,
94
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
95
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
92
+ this.db.issueSessions.commitIssueState({
93
+ writer: WRITER,
94
+ update: {
95
+ projectId: issue.projectId,
96
+ linearIssueId: issue.linearIssueId,
97
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
98
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
99
+ },
96
100
  });
97
101
  }
98
102
  reopenStaleLocalDoneIssue(issue, liveIssue) {
103
+ const buildReopenUpdate = (record) => {
104
+ const restored = resolveOpenWorkflowState(record);
105
+ return {
106
+ projectId: issue.projectId,
107
+ linearIssueId: issue.linearIssueId,
108
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
109
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
110
+ ...(restored ? { factoryState: restored.factoryState } : {}),
111
+ ...(restored ? { pendingRunType: restored.pendingRunType } : {}),
112
+ };
113
+ };
99
114
  const restored = resolveOpenWorkflowState(issue);
100
- this.db.issues.upsertIssue({
101
- projectId: issue.projectId,
102
- linearIssueId: issue.linearIssueId,
103
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
104
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
105
- ...(restored ? { factoryState: restored.factoryState } : {}),
106
- ...(restored ? { pendingRunType: restored.pendingRunType } : {}),
115
+ const commit = this.db.issueSessions.commitIssueState({
116
+ writer: WRITER,
117
+ expectedVersion: issue.version,
118
+ update: buildReopenUpdate(issue),
119
+ // Reopening a local done state must be re-derived against the fresh
120
+ // row when something else wrote in between — and only if it is
121
+ // still done.
122
+ onConflict: (current) => (current.factoryState === "done" ? buildReopenUpdate(current) : undefined),
107
123
  });
124
+ if (commit.outcome !== "applied") {
125
+ return;
126
+ }
108
127
  this.logger.info({
109
128
  issueKey: issue.issueKey,
110
129
  previousFactoryState: issue.factoryState,
@@ -116,11 +135,14 @@ export class MergedLinearCompletionReconciler {
116
135
  if (issue.currentLinearState === liveIssue.stateName && issue.currentLinearStateType === liveIssue.stateType) {
117
136
  return;
118
137
  }
119
- this.db.issues.upsertIssue({
120
- projectId: issue.projectId,
121
- linearIssueId: issue.linearIssueId,
122
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
123
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
138
+ this.db.issueSessions.commitIssueState({
139
+ writer: WRITER,
140
+ update: {
141
+ projectId: issue.projectId,
142
+ linearIssueId: issue.linearIssueId,
143
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
144
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
145
+ },
124
146
  });
125
147
  }
126
148
  isRecentCompletionCandidate(issue, now) {
@@ -1,6 +1,18 @@
1
1
  import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
2
2
  import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
3
3
  import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
4
+ const WRITER = "no-pr-completion-check";
5
+ // Post-completion-check decision writes all clear the run slot; on a version
6
+ // conflict, apply only if the slot still belongs to this run on the fresh row.
7
+ function commitRunSlotUpdate(db, run, issue, update) {
8
+ const commit = db.issueSessions.commitIssueState({
9
+ writer: WRITER,
10
+ expectedVersion: issue.version,
11
+ update,
12
+ onConflict: (current) => (current.activeRunId === run.id ? update : undefined),
13
+ });
14
+ return commit.outcome === "applied";
15
+ }
4
16
  function shouldContinueForUnpublishedLocalChanges(message) {
5
17
  const normalized = message.trim().toLowerCase();
6
18
  if (!normalized)
@@ -56,16 +68,18 @@ export async function handleNoPrCompletionCheck(params) {
56
68
  }
57
69
  if (completionCheck.outcome === "continue") {
58
70
  const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
59
- params.db.runs.finishRun(params.run.id, runUpdate);
60
- params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
61
- params.db.issues.upsertIssue({
71
+ if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
62
72
  projectId: params.run.projectId,
63
73
  linearIssueId: params.run.linearIssueId,
64
74
  activeRunId: null,
65
75
  factoryState: "delegated",
66
76
  pendingRunType: null,
67
77
  pendingRunContextJson: null,
68
- });
78
+ })) {
79
+ return false;
80
+ }
81
+ params.db.runs.finishRun(params.run.id, runUpdate);
82
+ params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
69
83
  return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
70
84
  projectId: params.run.projectId,
71
85
  linearIssueId: params.run.linearIssueId,
@@ -95,17 +109,19 @@ export async function handleNoPrCompletionCheck(params) {
95
109
  }
96
110
  if (completionCheck.outcome === "needs_input") {
97
111
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
98
- params.db.runs.finishRun(params.run.id, runUpdate);
99
- params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
100
- params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
101
- params.db.issues.upsertIssue({
112
+ if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
102
113
  projectId: params.run.projectId,
103
114
  linearIssueId: params.run.linearIssueId,
104
115
  activeRunId: null,
105
116
  factoryState: "awaiting_input",
106
117
  pendingRunType: null,
107
118
  pendingRunContextJson: null,
108
- });
119
+ })) {
120
+ return false;
121
+ }
122
+ params.db.runs.finishRun(params.run.id, runUpdate);
123
+ params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
124
+ params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
109
125
  return true;
110
126
  });
111
127
  if (!completed) {
@@ -127,20 +143,22 @@ export async function handleNoPrCompletionCheck(params) {
127
143
  if (completionCheck.outcome === "done") {
128
144
  if (shouldContinueForUnpublishedLocalChanges(params.publishedOutcomeError)) {
129
145
  const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
130
- params.db.runs.finishRun(params.run.id, runUpdate);
131
- params.db.runs.saveCompletionCheck(params.run.id, {
132
- ...completionCheck,
133
- outcome: "continue",
134
- summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
135
- why: params.publishedOutcomeError,
136
- });
137
- params.db.issues.upsertIssue({
146
+ if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
138
147
  projectId: params.run.projectId,
139
148
  linearIssueId: params.run.linearIssueId,
140
149
  activeRunId: null,
141
150
  factoryState: "delegated",
142
151
  pendingRunType: null,
143
152
  pendingRunContextJson: null,
153
+ })) {
154
+ return false;
155
+ }
156
+ params.db.runs.finishRun(params.run.id, runUpdate);
157
+ params.db.runs.saveCompletionCheck(params.run.id, {
158
+ ...completionCheck,
159
+ outcome: "continue",
160
+ summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
161
+ why: params.publishedOutcomeError,
144
162
  });
145
163
  return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
146
164
  projectId: params.run.projectId,
@@ -173,10 +191,7 @@ export async function handleNoPrCompletionCheck(params) {
173
191
  ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
174
192
  : 0;
175
193
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
176
- params.db.runs.finishRun(params.run.id, runUpdate);
177
- params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
178
- params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
179
- params.db.issues.upsertIssue({
194
+ if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
180
195
  projectId: params.run.projectId,
181
196
  linearIssueId: params.run.linearIssueId,
182
197
  activeRunId: null,
@@ -185,7 +200,12 @@ export async function handleNoPrCompletionCheck(params) {
185
200
  pendingRunContextJson: null,
186
201
  orchestrationSettleUntil: null,
187
202
  ...CLEARED_FAILURE_PROVENANCE,
188
- });
203
+ })) {
204
+ return false;
205
+ }
206
+ params.db.runs.finishRun(params.run.id, runUpdate);
207
+ params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
208
+ params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
189
209
  return true;
190
210
  });
191
211
  if (!completed) {
@@ -217,20 +237,22 @@ export async function handleNoPrCompletionCheck(params) {
217
237
  }
218
238
  const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
219
239
  const failed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, () => {
220
- params.db.runs.finishRun(params.run.id, {
221
- ...runUpdate,
222
- status: "failed",
223
- failureReason,
224
- });
225
- params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
226
- params.db.issues.upsertIssue({
240
+ if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
227
241
  projectId: params.run.projectId,
228
242
  linearIssueId: params.run.linearIssueId,
229
243
  activeRunId: null,
230
244
  factoryState: "failed",
231
245
  pendingRunType: null,
232
246
  pendingRunContextJson: null,
247
+ })) {
248
+ return false;
249
+ }
250
+ params.db.runs.finishRun(params.run.id, {
251
+ ...runUpdate,
252
+ status: "failed",
253
+ failureReason,
233
254
  });
255
+ params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
234
256
  return true;
235
257
  });
236
258
  if (!failed) {
@@ -1,4 +1,5 @@
1
1
  import { classifyIssue } from "./issue-class.js";
2
+ const WRITER = "orchestration-parent-wake";
2
3
  export const ORCHESTRATION_SETTLE_WINDOW_MS = 10_000;
3
4
  export function computeOrchestrationSettleUntil(now = Date.now()) {
4
5
  return new Date(now + ORCHESTRATION_SETTLE_WINDOW_MS).toISOString();
@@ -24,18 +25,24 @@ function resolveParentIssueIds(db, child) {
24
25
  }
25
26
  export function startOrchestrationSettleWindow(db, issue, now = Date.now()) {
26
27
  const settleUntil = computeOrchestrationSettleUntil(now);
27
- db.issues.upsertIssue({
28
- projectId: issue.projectId,
29
- linearIssueId: issue.linearIssueId,
30
- orchestrationSettleUntil: settleUntil,
28
+ db.issueSessions.commitIssueState({
29
+ writer: WRITER,
30
+ update: {
31
+ projectId: issue.projectId,
32
+ linearIssueId: issue.linearIssueId,
33
+ orchestrationSettleUntil: settleUntil,
34
+ },
31
35
  });
32
36
  return settleUntil;
33
37
  }
34
38
  export function queueSettledOrchestrationIssue(params) {
35
- params.db.issues.upsertIssue({
36
- projectId: params.issue.projectId,
37
- linearIssueId: params.issue.linearIssueId,
38
- orchestrationSettleUntil: null,
39
+ params.db.issueSessions.commitIssueState({
40
+ writer: WRITER,
41
+ update: {
42
+ projectId: params.issue.projectId,
43
+ linearIssueId: params.issue.linearIssueId,
44
+ orchestrationSettleUntil: null,
45
+ },
39
46
  });
40
47
  const dispatched = params.wakeDispatcher.recordEventAndDispatch(params.issue.projectId, params.issue.linearIssueId, {
41
48
  eventType: "delegated",
@@ -1,6 +1,7 @@
1
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
2
  import { buildRepairWakeDedupeKey } from "./reactive-wake-keys.js";
3
3
  import { execCommand } from "./utils.js";
4
+ const WRITER = "queue-health-monitor";
4
5
  const QUEUE_HEALTH_GRACE_MS = 120_000;
5
6
  const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
6
7
  // Plan §6.2: an approved PR with red branch CI for >= this long is
@@ -113,8 +114,12 @@ export class QueueHealthMonitor {
113
114
  }
114
115
  this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
115
116
  if (pr.state === "MERGED") {
116
- this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
117
- this.advancer.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
117
+ const mergedCommit = this.db.issueSessions.commitIssueState({
118
+ writer: WRITER,
119
+ update: { projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" },
120
+ });
121
+ const merged = mergedCommit.outcome === "applied" ? mergedCommit.issue : issue;
122
+ this.advancer.advanceIdleIssue(merged, "done", { clearFailureProvenance: true });
118
123
  return;
119
124
  }
120
125
  if (pr.state !== "OPEN")
@@ -159,12 +164,16 @@ export class QueueHealthMonitor {
159
164
  if (isDuplicateProbe(issue, pendingRunContext)) {
160
165
  return;
161
166
  }
162
- this.db.issues.upsertIssue({
163
- projectId: issue.projectId,
164
- linearIssueId: issue.linearIssueId,
165
- lastAttemptedFailureHeadSha: headRefOid,
166
- lastAttemptedFailureSignature: signature,
167
+ const probedCommit = this.db.issueSessions.commitIssueState({
168
+ writer: WRITER,
169
+ update: {
170
+ projectId: issue.projectId,
171
+ linearIssueId: issue.linearIssueId,
172
+ lastAttemptedFailureHeadSha: headRefOid,
173
+ lastAttemptedFailureSignature: signature,
174
+ },
167
175
  });
176
+ const probed = probedCommit.outcome === "applied" ? probedCommit.issue : issue;
168
177
  this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
169
178
  eventType: "merge_steward_incident",
170
179
  eventJson: JSON.stringify(pendingRunContext),
@@ -175,7 +184,7 @@ export class QueueHealthMonitor {
175
184
  signature,
176
185
  }),
177
186
  });
178
- this.advancer.advanceIdleIssue(issue, "repairing_queue");
187
+ this.advancer.advanceIdleIssue(probed, "repairing_queue");
179
188
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
180
189
  this.feed?.publish({
181
190
  level: "warn",
@@ -2,6 +2,7 @@ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
2
2
  import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
3
3
  import { readReactivePublishDelta } from "./reactive-publish-delta.js";
4
4
  import { readLatestRequestedChangesReviewContext } from "./remote-pr-review.js";
5
+ const WRITER = "reactive-run-policy";
5
6
  const REACTIVE_SCOPE_RISK_PREFIXES = [
6
7
  ".github/workflows/",
7
8
  "scripts/bootstrap-worktree.",
@@ -273,7 +274,10 @@ export class ReactiveRunPolicy {
273
274
  }
274
275
  }
275
276
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
276
- const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
277
+ const updated = this.withHeldLease(projectId, linearIssueId, (lease) => {
278
+ const commit = this.db.issueSessions.commitIssueState({ writer: WRITER, lease, update: params });
279
+ return commit.outcome === "applied" ? commit.issue : undefined;
280
+ });
277
281
  if (updated === undefined) {
278
282
  this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
279
283
  }
@@ -6,6 +6,7 @@ import { resolveCompletedRunState } from "./run-completion-policy.js";
6
6
  import { computeChangeIdentityFromWorktree } from "./change-identity.js";
7
7
  import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
8
8
  import { buildRunOutcomeSummary } from "./run-outcome-summary.js";
9
+ const WRITER = "run-finalizer";
9
10
  function parseEventJson(eventJson) {
10
11
  if (!eventJson)
11
12
  return undefined;
@@ -150,12 +151,16 @@ export class RunFinalizer {
150
151
  });
151
152
  if (!identity.patchId && !identity.integrationTreeId)
152
153
  return;
153
- this.db.issues.upsertIssue({
154
- projectId: issue.projectId,
155
- linearIssueId: issue.linearIssueId,
156
- ...(identity.patchId ? { lastPublishedPatchId: identity.patchId } : {}),
157
- ...(identity.integrationTreeId ? { lastPublishedIntegrationTreeId: identity.integrationTreeId } : {}),
158
- lastPublishedHeadSha: issue.prHeadSha,
154
+ this.db.issueSessions.commitIssueState({
155
+ writer: WRITER,
156
+ expectedVersion: issue.version,
157
+ update: {
158
+ projectId: issue.projectId,
159
+ linearIssueId: issue.linearIssueId,
160
+ ...(identity.patchId ? { lastPublishedPatchId: identity.patchId } : {}),
161
+ ...(identity.integrationTreeId ? { lastPublishedIntegrationTreeId: identity.integrationTreeId } : {}),
162
+ lastPublishedHeadSha: issue.prHeadSha,
163
+ },
159
164
  });
160
165
  this.logger.info({
161
166
  issueKey: issue.issueKey,
@@ -194,12 +199,15 @@ export class RunFinalizer {
194
199
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
195
200
  failureReason: run.failureReason ?? "approved on the same head; further publication suppressed",
196
201
  });
197
- this.db.issues.upsertIssue({
198
- projectId: run.projectId,
199
- linearIssueId: run.linearIssueId,
200
- activeRunId: null,
201
- pendingRunType: null,
202
- pendingRunContextJson: null,
202
+ this.db.issueSessions.commitIssueState({
203
+ writer: WRITER,
204
+ update: {
205
+ projectId: run.projectId,
206
+ linearIssueId: run.linearIssueId,
207
+ activeRunId: null,
208
+ pendingRunType: null,
209
+ pendingRunContextJson: null,
210
+ },
203
211
  });
204
212
  });
205
213
  this.clearProgressAndRelease(run);
@@ -274,23 +282,33 @@ export class RunFinalizer {
274
282
  report: params.report,
275
283
  outcomeSummary,
276
284
  }));
277
- this.db.issueSessions.upsertIssueWithLease(lease, {
285
+ // The attempt decrements are read-modify-write against the issue row;
286
+ // on conflict, recompute them from the fresh row instead of writing
287
+ // counters derived from a stale read.
288
+ const buildContinueUpdate = (record) => ({
278
289
  projectId: params.run.projectId,
279
290
  linearIssueId: params.run.linearIssueId,
280
291
  activeRunId: null,
281
292
  factoryState: "delegated",
282
293
  pendingRunType: null,
283
294
  pendingRunContextJson: null,
284
- ...(params.run.runType === "ci_repair" && params.issue.ciRepairAttempts > 0
285
- ? { ciRepairAttempts: params.issue.ciRepairAttempts - 1 }
295
+ ...(params.run.runType === "ci_repair" && record.ciRepairAttempts > 0
296
+ ? { ciRepairAttempts: record.ciRepairAttempts - 1 }
286
297
  : {}),
287
- ...(params.run.runType === "queue_repair" && params.issue.queueRepairAttempts > 0
288
- ? { queueRepairAttempts: params.issue.queueRepairAttempts - 1 }
298
+ ...(params.run.runType === "queue_repair" && record.queueRepairAttempts > 0
299
+ ? { queueRepairAttempts: record.queueRepairAttempts - 1 }
289
300
  : {}),
290
- ...((params.run.runType === "review_fix" || params.run.runType === "branch_upkeep") && params.issue.reviewFixAttempts > 0
291
- ? { reviewFixAttempts: params.issue.reviewFixAttempts - 1 }
301
+ ...((params.run.runType === "review_fix" || params.run.runType === "branch_upkeep") && record.reviewFixAttempts > 0
302
+ ? { reviewFixAttempts: record.reviewFixAttempts - 1 }
292
303
  : {}),
293
304
  });
305
+ this.db.issueSessions.commitIssueState({
306
+ writer: WRITER,
307
+ lease,
308
+ expectedVersion: params.issue.version,
309
+ update: buildContinueUpdate(params.issue),
310
+ onConflict: (current) => buildContinueUpdate(current),
311
+ });
294
312
  return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
295
313
  projectId: params.run.projectId,
296
314
  linearIssueId: params.run.linearIssueId,
@@ -435,23 +453,37 @@ export class RunFinalizer {
435
453
  postRunState,
436
454
  latestAssistantSummary: report.assistantMessages.at(-1),
437
455
  });
438
- const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
439
- this.db.runs.finishRun(run.id, this.buildCompletedRunUpdate({
440
- threadId,
441
- ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
442
- report,
443
- outcomeSummary,
444
- }));
445
- this.db.issues.upsertIssue({
456
+ // `refreshedIssue` was read before several async policy checks; a
457
+ // version conflict here means a webhook landed mid-finalize. Re-resolve
458
+ // the post-run state from the fresh row so we never regress it (e.g.
459
+ // the PR merged while we were verifying the publish).
460
+ const buildCompletionUpdate = (record) => {
461
+ const state = postRunFollowUp?.factoryState ?? resolveCompletedRunState(record, run);
462
+ return {
446
463
  projectId: run.projectId,
447
464
  linearIssueId: run.linearIssueId,
448
465
  activeRunId: null,
449
- ...(postRunState ? { factoryState: postRunState } : {}),
466
+ ...(state ? { factoryState: state } : {}),
450
467
  pendingRunType: null,
451
468
  pendingRunContextJson: null,
452
- ...(postRunFollowUp ? {} : (postRunState === "awaiting_queue" || postRunState === "done"
469
+ ...(postRunFollowUp ? {} : (state === "awaiting_queue" || state === "done"
453
470
  ? { ...CLEARED_FAILURE_PROVENANCE }
454
471
  : {})),
472
+ };
473
+ };
474
+ const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
475
+ this.db.runs.finishRun(run.id, this.buildCompletedRunUpdate({
476
+ threadId,
477
+ ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
478
+ report,
479
+ outcomeSummary,
480
+ }));
481
+ this.db.issueSessions.commitIssueState({
482
+ writer: WRITER,
483
+ lease,
484
+ expectedVersion: refreshedIssue.version,
485
+ update: buildCompletionUpdate(refreshedIssue),
486
+ onConflict: (current) => buildCompletionUpdate(current),
455
487
  });
456
488
  if (postRunFollowUp) {
457
489
  return this.appendWakeEventWithLease(lease, issue, postRunFollowUp.pendingRunType, postRunFollowUp.context, "post_run");
@@ -4,6 +4,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
4
4
  import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
5
5
  import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
6
6
  import { sanitizeDiagnosticText } from "./utils.js";
7
+ const WRITER = "run-launcher";
7
8
  function slugify(value) {
8
9
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
9
10
  }
@@ -128,7 +129,7 @@ export class RunLauncher {
128
129
  ? params.effectiveContext.failureHeadSha
129
130
  : typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
130
131
  const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
131
- this.db.issues.upsertIssue({
132
+ const claimUpdate = {
132
133
  projectId: params.item.projectId,
133
134
  linearIssueId: params.item.issueId,
134
135
  pendingRunType: null,
@@ -148,7 +149,18 @@ export class RunLauncher {
148
149
  lastAttemptedFailureAt: new Date().toISOString(),
149
150
  }
150
151
  : {}),
152
+ };
153
+ const claimCommit = this.db.issueSessions.commitIssueState({
154
+ writer: WRITER,
155
+ // `wakeIssue` is the freshest row this claim transaction has seen
156
+ // (materializeLegacyPendingWake may have bumped the version).
157
+ expectedVersion: wakeIssue.version,
158
+ update: claimUpdate,
159
+ // Never steal a slot another writer claimed concurrently.
160
+ onConflict: (current) => (current.activeRunId == null ? claimUpdate : undefined),
151
161
  });
162
+ if (claimCommit.outcome !== "applied")
163
+ return undefined;
152
164
  this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
153
165
  this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
154
166
  return created;
@@ -201,7 +213,11 @@ export class RunLauncher {
201
213
  const thread = await this.codex.startThread({ cwd: params.worktreePath });
202
214
  threadId = thread.id;
203
215
  createdThreadForRun = true;
204
- this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
216
+ this.db.issueSessions.commitIssueState({
217
+ writer: WRITER,
218
+ lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
219
+ update: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId },
220
+ });
205
221
  }
206
222
  this.db.runs.updateLaunchPhase(params.run.id, "thread_started");
207
223
  try {
@@ -216,7 +232,11 @@ export class RunLauncher {
216
232
  const thread = await this.codex.startThread({ cwd: params.worktreePath });
217
233
  threadId = thread.id;
218
234
  createdThreadForRun = true;
219
- this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
235
+ this.db.issueSessions.commitIssueState({
236
+ writer: WRITER,
237
+ lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
238
+ update: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId },
239
+ });
220
240
  const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
221
241
  turnId = turn.turnId;
222
242
  this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
@@ -236,15 +256,25 @@ export class RunLauncher {
236
256
  const lostLease = error instanceof Error && error.name === "IssueSessionLeaseLostError";
237
257
  if (!lostLease) {
238
258
  const nextState = resolveFailureFactoryState(params.runType);
239
- this.db.issueSessions.finishRunWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, params.run.id, {
240
- status: "failed",
241
- failureReason: message,
242
- });
243
- this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, {
244
- projectId: params.project.id,
245
- linearIssueId: params.issue.linearIssueId,
246
- activeRunId: null,
247
- factoryState: nextState,
259
+ // Issue clear + run-terminal write ride in one transaction; the run
260
+ // finish is gated on the issue commit so a lost lease skips both.
261
+ this.db.transaction(() => {
262
+ const commit = this.db.issueSessions.commitIssueState({
263
+ writer: WRITER,
264
+ lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
265
+ update: {
266
+ projectId: params.project.id,
267
+ linearIssueId: params.issue.linearIssueId,
268
+ activeRunId: null,
269
+ factoryState: nextState,
270
+ },
271
+ });
272
+ if (commit.outcome !== "applied")
273
+ return;
274
+ this.db.runs.finishRun(params.run.id, {
275
+ status: "failed",
276
+ failureReason: message,
277
+ });
248
278
  });
249
279
  }
250
280
  this.logger.error({ issueKey: params.issue.issueKey, runType: params.runType, error: message }, `Failed to launch ${params.runType} run`);
@@ -2,6 +2,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
2
2
  import { extractTurnId, resolveRunCompletionStatus } from "./run-reporting.js";
3
3
  import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
4
4
  import { resolveFailureFactoryState } from "./reactive-pr-state.js";
5
+ const WRITER = "run-notification-handler";
5
6
  const DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
6
7
  export class RunNotificationHandler {
7
8
  config;
@@ -90,19 +91,30 @@ export class RunNotificationHandler {
90
91
  return;
91
92
  }
92
93
  const nextState = resolveFailureFactoryState(run.runType);
94
+ const failureUpdate = {
95
+ projectId: run.projectId,
96
+ linearIssueId: run.linearIssueId,
97
+ activeRunId: null,
98
+ factoryState: nextState,
99
+ };
93
100
  const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
94
- this.db.issueSessions.finishRunWithLease(lease, run.id, {
101
+ const commit = this.db.issueSessions.commitIssueState({
102
+ writer: WRITER,
103
+ lease,
104
+ // The issue row was read before awaiting the failed-run recovery;
105
+ // only clear the slot if it still belongs to this run.
106
+ expectedVersion: issue.version,
107
+ update: failureUpdate,
108
+ onConflict: (current) => (current.activeRunId === run.id ? failureUpdate : undefined),
109
+ });
110
+ if (commit.outcome !== "applied")
111
+ return false;
112
+ this.db.runs.finishRun(run.id, {
95
113
  status: "failed",
96
114
  threadId,
97
115
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
98
116
  failureReason,
99
117
  });
100
- this.db.issueSessions.upsertIssueWithLease(lease, {
101
- projectId: run.projectId,
102
- linearIssueId: run.linearIssueId,
103
- activeRunId: null,
104
- factoryState: nextState,
105
- });
106
118
  return true;
107
119
  });
108
120
  if (!updated) {