patchrelay 0.75.3 → 0.77.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 (45) 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 +11 -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/issue-session-projection-invalidator.js +9 -0
  15. package/dist/linear-agent-session-client.js +16 -8
  16. package/dist/linear-issue-projection.js +15 -11
  17. package/dist/linear-status-comment-sync.js +8 -4
  18. package/dist/linear-workflow-state-sync.js +9 -5
  19. package/dist/merged-linear-completion-reconciler.js +39 -17
  20. package/dist/no-pr-completion-check.js +51 -29
  21. package/dist/orchestration-parent-wake.js +15 -8
  22. package/dist/queue-health-monitor.js +17 -8
  23. package/dist/reactive-run-policy.js +5 -1
  24. package/dist/run-budgets.js +40 -6
  25. package/dist/run-completion-policy.js +50 -9
  26. package/dist/run-failure-policy.js +463 -0
  27. package/dist/run-finalizer.js +68 -35
  28. package/dist/run-launcher.js +63 -12
  29. package/dist/run-notification-handler.js +19 -9
  30. package/dist/run-orchestrator.js +70 -78
  31. package/dist/run-reconciler.js +137 -64
  32. package/dist/run-settlement.js +57 -0
  33. package/dist/run-wake-planner.js +39 -29
  34. package/dist/service-issue-actions.js +45 -28
  35. package/dist/service-startup-recovery.js +61 -35
  36. package/dist/telemetry.js +9 -0
  37. package/dist/terminal-wake-reconciler.js +20 -3
  38. package/dist/webhooks/agent-session-handler.js +22 -12
  39. package/dist/webhooks/dependency-readiness-handler.js +17 -10
  40. package/dist/webhooks/desired-stage-recorder.js +32 -13
  41. package/dist/webhooks/issue-removal-handler.js +24 -13
  42. package/package.json +1 -1
  43. package/dist/interrupted-run-recovery.js +0 -227
  44. package/dist/run-recovery-service.js +0 -202
  45. package/dist/zombie-recovery.js +0 -13
@@ -3,11 +3,12 @@ import { TERMINAL_STATES } from "./factory-state.js";
3
3
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
4
4
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
5
5
  import { getThreadTurns } from "./codex-thread-utils.js";
6
- import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
7
6
  import { resolveEffectiveActiveRun } from "./effective-active-run.js";
8
7
  import { isThreadMaterializingError } from "./codex-thread-errors.js";
9
8
  import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
9
+ import { emitTelemetry, noopTelemetry } from "./telemetry.js";
10
10
  const THREAD_MATERIALIZATION_GRACE_MS = 10 * 60_000;
11
+ const WRITER = "run-reconciler";
11
12
  function isWithinThreadMaterializationGrace(run, nowMs = Date.now()) {
12
13
  const startedAtMs = Date.parse(run.startedAt);
13
14
  if (!Number.isFinite(startedAtMs))
@@ -19,27 +20,27 @@ export class RunReconciler {
19
20
  logger;
20
21
  linearProvider;
21
22
  linearSync;
22
- interruptedRunRecovery;
23
+ failurePolicy;
23
24
  runFinalizer;
24
25
  withHeldLease;
25
26
  releaseLease;
26
27
  readThreadWithRetry;
27
- recoverOrEscalate;
28
28
  resolveRepoFullName;
29
29
  feed;
30
- constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, resolveRepoFullName = () => undefined, feed) {
30
+ telemetry;
31
+ constructor(db, logger, linearProvider, linearSync, failurePolicy, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, resolveRepoFullName = () => undefined, feed, telemetry = noopTelemetry) {
31
32
  this.db = db;
32
33
  this.logger = logger;
33
34
  this.linearProvider = linearProvider;
34
35
  this.linearSync = linearSync;
35
- this.interruptedRunRecovery = interruptedRunRecovery;
36
+ this.failurePolicy = failurePolicy;
36
37
  this.runFinalizer = runFinalizer;
37
38
  this.withHeldLease = withHeldLease;
38
39
  this.releaseLease = releaseLease;
39
40
  this.readThreadWithRetry = readThreadWithRetry;
40
- this.recoverOrEscalate = recoverOrEscalate;
41
41
  this.resolveRepoFullName = resolveRepoFullName;
42
42
  this.feed = feed;
43
+ this.telemetry = telemetry;
43
44
  }
44
45
  async reconcile(params) {
45
46
  const { run, issue, recoveryLease } = params;
@@ -50,13 +51,39 @@ export class RunReconciler {
50
51
  latestRun: run,
51
52
  });
52
53
  if (effectiveActiveRun?.id === run.id && issue.activeRunId !== run.id) {
53
- effectiveIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
54
+ const reattachUpdate = {
54
55
  projectId: run.projectId,
55
56
  linearIssueId: run.linearIssueId,
56
57
  activeRunId: run.id,
57
58
  ...(run.threadId ? { threadId: run.threadId } : {}),
58
- })) ?? effectiveIssue;
59
- this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Reattached detached active run during reconciliation");
59
+ };
60
+ const commit = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issueSessions.commitIssueState({
61
+ writer: WRITER,
62
+ expectedVersion: issue.version,
63
+ update: reattachUpdate,
64
+ // Never steal the slot from a run that was attached concurrently.
65
+ onConflict: (current) => (current.activeRunId == null ? reattachUpdate : undefined),
66
+ }));
67
+ if (commit?.outcome === "applied") {
68
+ effectiveIssue = commit.issue;
69
+ this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Reattached detached active run during reconciliation");
70
+ // Plan §B5: with settleRun idempotent and the launcher persisting the
71
+ // thread id before startTurn, this reattachment should never fire.
72
+ // Telemetry observes it for one release before the block is deleted.
73
+ emitTelemetry(this.telemetry, {
74
+ type: "health.invariant",
75
+ invariant: "detached_active_run",
76
+ status: "repaired",
77
+ projectId: run.projectId,
78
+ linearIssueId: run.linearIssueId,
79
+ ...(effectiveIssue.issueKey ? { issueKey: effectiveIssue.issueKey } : {}),
80
+ runId: run.id,
81
+ detail: `Reattached detached active ${run.runType} run during reconciliation`,
82
+ });
83
+ }
84
+ else if (commit?.outcome === "conflict_skipped" && commit.issue) {
85
+ effectiveIssue = commit.issue;
86
+ }
60
87
  }
61
88
  if (!effectiveIssue.delegatedToPatchRelay) {
62
89
  const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
@@ -69,9 +96,18 @@ export class RunReconciler {
69
96
  }
70
97
  }
71
98
  if (TERMINAL_STATES.has(effectiveIssue.factoryState)) {
99
+ const terminalClear = { projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null };
72
100
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
101
+ const commit = this.db.issueSessions.commitIssueState({
102
+ writer: WRITER,
103
+ expectedVersion: effectiveIssue.version,
104
+ update: terminalClear,
105
+ // Re-check the release predicate against the fresh row.
106
+ onConflict: (current) => TERMINAL_STATES.has(current.factoryState) && current.activeRunId === run.id ? terminalClear : undefined,
107
+ });
108
+ if (commit.outcome !== "applied")
109
+ return;
73
110
  this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
74
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
75
111
  });
76
112
  this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, factoryState: effectiveIssue.factoryState }, "Reconciliation: released run on terminal issue");
77
113
  const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
@@ -88,11 +124,14 @@ export class RunReconciler {
88
124
  return;
89
125
  }
90
126
  this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
91
- this.withHeldLease(run.projectId, run.linearIssueId, () => {
92
- this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
93
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
127
+ // Detection only — the failure policy settles the run and decides
128
+ // retry vs escalate (plan §B4).
129
+ this.failurePolicy.settleStrandedRunAndRecover({
130
+ run,
131
+ issue: effectiveIssue,
132
+ reason: "zombie",
133
+ failureReason: "Zombie: never started (no thread after restart)",
94
134
  });
95
- this.recoverOrEscalate(effectiveIssue, run.runType, "zombie");
96
135
  const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
97
136
  void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
98
137
  void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
@@ -113,11 +152,14 @@ export class RunReconciler {
113
152
  return;
114
153
  }
115
154
  this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
116
- this.withHeldLease(run.projectId, run.linearIssueId, () => {
117
- this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
118
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
155
+ // Detection only — the failure policy settles the run and decides
156
+ // retry vs escalate (plan §B4).
157
+ this.failurePolicy.settleStrandedRunAndRecover({
158
+ run,
159
+ issue: effectiveIssue,
160
+ reason: "stale_thread",
161
+ failureReason: "Stale thread after restart",
119
162
  });
120
- this.recoverOrEscalate(effectiveIssue, run.runType, "stale_thread");
121
163
  const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
122
164
  void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
123
165
  void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
@@ -130,15 +172,25 @@ export class RunReconciler {
130
172
  if (linearIssue) {
131
173
  const stopState = resolveAuthoritativeLinearStopState(linearIssue);
132
174
  if (stopState?.isFinal) {
175
+ const stopUpdate = {
176
+ projectId: run.projectId,
177
+ linearIssueId: run.linearIssueId,
178
+ activeRunId: null,
179
+ currentLinearState: stopState.stateName,
180
+ factoryState: "done",
181
+ };
133
182
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
134
- this.db.runs.finishRun(run.id, { status: "released" });
135
- this.db.issues.upsertIssue({
136
- projectId: run.projectId,
137
- linearIssueId: run.linearIssueId,
138
- activeRunId: null,
139
- currentLinearState: stopState.stateName,
140
- factoryState: "done",
183
+ const commit = this.db.issueSessions.commitIssueState({
184
+ writer: WRITER,
185
+ expectedVersion: effectiveIssue.version,
186
+ // The Linear stop state is authoritative; only the run-slot
187
+ // ownership needs re-checking on conflict.
188
+ update: stopUpdate,
189
+ onConflict: (current) => (current.activeRunId === run.id ? stopUpdate : undefined),
141
190
  });
191
+ if (commit.outcome !== "applied")
192
+ return;
193
+ this.db.runs.finishRun(run.id, { status: "released" });
142
194
  });
143
195
  this.feed?.publish({
144
196
  level: "info",
@@ -158,7 +210,7 @@ export class RunReconciler {
158
210
  }
159
211
  const latestTurn = getThreadTurns(thread).at(-1);
160
212
  if (latestTurn?.status === "interrupted") {
161
- await this.interruptedRunRecovery.handle(run, effectiveIssue);
213
+ await this.failurePolicy.handleInterruptedRun(run, effectiveIssue);
162
214
  return;
163
215
  }
164
216
  if (latestTurn?.status === "completed") {
@@ -169,7 +221,6 @@ export class RunReconciler {
169
221
  thread,
170
222
  threadId: run.threadId,
171
223
  ...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
172
- resolveRecoverableRunState: resolveRecoverablePostRunState,
173
224
  });
174
225
  return;
175
226
  }
@@ -198,21 +249,31 @@ export class RunReconciler {
198
249
  return true;
199
250
  }
200
251
  releaseMergedRun(run, issue, reason) {
252
+ const mergedUpdate = {
253
+ projectId: run.projectId,
254
+ linearIssueId: run.linearIssueId,
255
+ activeRunId: null,
256
+ factoryState: "done",
257
+ prState: "merged",
258
+ pendingRunType: null,
259
+ pendingRunContextJson: null,
260
+ };
201
261
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
262
+ const commit = this.db.issueSessions.commitIssueState({
263
+ writer: WRITER,
264
+ expectedVersion: issue.version,
265
+ // The merge itself is external truth; only re-check that the run
266
+ // slot still belongs to this run before clearing it.
267
+ update: mergedUpdate,
268
+ onConflict: (current) => (current.activeRunId === run.id ? mergedUpdate : undefined),
269
+ });
270
+ if (commit.outcome !== "applied")
271
+ return;
202
272
  this.db.issueSessions.clearPendingIssueSessionEvents(run.projectId, run.linearIssueId);
203
273
  this.db.runs.finishRun(run.id, {
204
274
  status: "released",
205
275
  failureReason: reason,
206
276
  });
207
- this.db.issues.upsertIssue({
208
- projectId: run.projectId,
209
- linearIssueId: run.linearIssueId,
210
- activeRunId: null,
211
- factoryState: "done",
212
- prState: "merged",
213
- pendingRunType: null,
214
- pendingRunContextJson: null,
215
- });
216
277
  });
217
278
  this.feed?.publish({
218
279
  level: "info",
@@ -287,19 +348,26 @@ export class RunReconciler {
287
348
  },
288
349
  });
289
350
  if (delegated) {
290
- const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
291
- projectId: run.projectId,
292
- linearIssueId: run.linearIssueId,
293
- delegatedToPatchRelay: true,
294
- ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
295
- ...(linearIssue.title ? { title: linearIssue.title } : {}),
296
- ...(linearIssue.description ? { description: linearIssue.description } : {}),
297
- ...(linearIssue.url ? { url: linearIssue.url } : {}),
298
- ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
299
- ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
300
- ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
301
- ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
302
- })) ?? issue;
351
+ // Live Linear is the authority on delegation; commit unconditionally.
352
+ const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => {
353
+ const commit = this.db.issueSessions.commitIssueState({
354
+ writer: WRITER,
355
+ update: {
356
+ projectId: run.projectId,
357
+ linearIssueId: run.linearIssueId,
358
+ delegatedToPatchRelay: true,
359
+ ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
360
+ ...(linearIssue.title ? { title: linearIssue.title } : {}),
361
+ ...(linearIssue.description ? { description: linearIssue.description } : {}),
362
+ ...(linearIssue.url ? { url: linearIssue.url } : {}),
363
+ ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
364
+ ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
365
+ ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
366
+ ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
367
+ },
368
+ });
369
+ return commit.outcome === "applied" ? commit.issue : undefined;
370
+ }) ?? issue;
303
371
  return { issue: repairedIssue, released: false };
304
372
  }
305
373
  appendRunReleasedAuthorityEvent(this.db, {
@@ -315,22 +383,27 @@ export class RunReconciler {
315
383
  },
316
384
  });
317
385
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
318
- this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
319
- this.db.issues.upsertIssue({
320
- projectId: run.projectId,
321
- linearIssueId: run.linearIssueId,
322
- activeRunId: null,
323
- factoryState: issue.factoryState,
324
- delegatedToPatchRelay: false,
325
- ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
326
- ...(linearIssue.title ? { title: linearIssue.title } : {}),
327
- ...(linearIssue.description ? { description: linearIssue.description } : {}),
328
- ...(linearIssue.url ? { url: linearIssue.url } : {}),
329
- ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
330
- ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
331
- ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
332
- ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
386
+ // Undelegation confirmed against live Linear external truth, commit
387
+ // unconditionally; the run release rides in the same transaction.
388
+ this.db.issueSessions.commitIssueState({
389
+ writer: WRITER,
390
+ update: {
391
+ projectId: run.projectId,
392
+ linearIssueId: run.linearIssueId,
393
+ activeRunId: null,
394
+ factoryState: issue.factoryState,
395
+ delegatedToPatchRelay: false,
396
+ ...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
397
+ ...(linearIssue.title ? { title: linearIssue.title } : {}),
398
+ ...(linearIssue.description ? { description: linearIssue.description } : {}),
399
+ ...(linearIssue.url ? { url: linearIssue.url } : {}),
400
+ ...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
401
+ ...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
402
+ ...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
403
+ ...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
404
+ },
333
405
  });
406
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
334
407
  });
335
408
  return { issue, released: true };
336
409
  }
@@ -0,0 +1,57 @@
1
+ const WRITER = "run-settlement";
2
+ const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "released", "superseded"]);
3
+ export function isTerminalRunStatus(status) {
4
+ return TERMINAL_RUN_STATUSES.has(status);
5
+ }
6
+ // Phase B1 (core simplification plan): the fast, transactional, idempotent
7
+ // half of run finalization. One transaction marks the run terminal and
8
+ // clears the issue's active slot — the two writes whose separation caused
9
+ // the dangling-active-run freeze (PR #566): a restart landing between them
10
+ // left `activeRunId` pointing at a terminal run forever, hiding the issue
11
+ // from every idle/recovery pass. Safe to call from both the notification
12
+ // finalizer and reconciliation at any time:
13
+ // - already-terminal run → finishRun skipped;
14
+ // - slot already cleared or re-pointed at another run → issue untouched;
15
+ // - non-terminal run with no `finish` outcome → full no-op.
16
+ export function settleRun(params) {
17
+ const { db, run } = params;
18
+ return db.transaction(() => {
19
+ const freshRun = db.runs.getRunById(run.id);
20
+ if (!freshRun) {
21
+ return { runFinished: false, slotCleared: false, issue: db.issues.getIssue(run.projectId, run.linearIssueId) };
22
+ }
23
+ let runFinished = false;
24
+ if (!isTerminalRunStatus(freshRun.status)) {
25
+ if (!params.finish) {
26
+ return { runFinished: false, slotCleared: false, issue: db.issues.getIssue(run.projectId, run.linearIssueId) };
27
+ }
28
+ db.runs.finishRun(run.id, params.finish);
29
+ runFinished = true;
30
+ }
31
+ const current = db.issues.getIssue(run.projectId, run.linearIssueId);
32
+ if (!current || current.activeRunId !== run.id) {
33
+ return { runFinished, slotCleared: false, issue: current };
34
+ }
35
+ const buildUpdate = (record) => ({
36
+ projectId: run.projectId,
37
+ linearIssueId: run.linearIssueId,
38
+ ...params.buildIssueUpdate?.(record),
39
+ // After the caller-provided fields so nothing can override the clear.
40
+ activeRunId: null,
41
+ });
42
+ const commit = db.issueSessions.commitIssueState({
43
+ writer: WRITER,
44
+ ...(params.lease ? { lease: params.lease } : {}),
45
+ expectedVersion: current.version,
46
+ update: buildUpdate(current),
47
+ // The read above happened inside this same transaction, so a version
48
+ // conflict cannot normally occur; the predicate keeps the invariant
49
+ // explicit: never clear a slot that was re-pointed at another run.
50
+ onConflict: (fresh) => (fresh.activeRunId === run.id ? buildUpdate(fresh) : undefined),
51
+ });
52
+ if (commit.outcome !== "applied") {
53
+ return { runFinished, slotCleared: false, issue: commit.outcome === "conflict_skipped" ? commit.issue : current };
54
+ }
55
+ return { runFinished, slotCleared: true, issue: commit.issue };
56
+ });
57
+ }
@@ -1,5 +1,6 @@
1
1
  import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
2
2
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
3
+ const WRITER = "run-wake-planner";
3
4
  export class RunWakePlanner {
4
5
  db;
5
6
  constructor(db) {
@@ -62,15 +63,19 @@ export class RunWakePlanner {
62
63
  ? JSON.parse(issue.pendingRunContextJson)
63
64
  : undefined;
64
65
  this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
65
- const updated = this.db.issueSessions.upsertIssueWithLease(lease, {
66
- projectId: issue.projectId,
67
- linearIssueId: issue.linearIssueId,
68
- pendingRunType: null,
69
- pendingRunContextJson: null,
66
+ const commit = this.db.issueSessions.commitIssueState({
67
+ writer: WRITER,
68
+ lease,
69
+ update: {
70
+ projectId: issue.projectId,
71
+ linearIssueId: issue.linearIssueId,
72
+ pendingRunType: null,
73
+ pendingRunContextJson: null,
74
+ },
70
75
  });
71
- if (!updated)
76
+ if (commit.outcome !== "applied")
72
77
  return issue;
73
- return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
78
+ return commit.issue;
74
79
  }
75
80
  budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
76
81
  const ciRepairBudget = getCiRepairBudget(project);
@@ -88,27 +93,32 @@ export class RunWakePlanner {
88
93
  return undefined;
89
94
  }
90
95
  incrementAttemptCounters(issue, lease, runType, isRequestedChangesRunType) {
91
- if (runType === "ci_repair") {
92
- return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
93
- projectId: issue.projectId,
94
- linearIssueId: issue.linearIssueId,
95
- ciRepairAttempts: issue.ciRepairAttempts + 1,
96
- }));
97
- }
98
- if (runType === "queue_repair") {
99
- return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
100
- projectId: issue.projectId,
101
- linearIssueId: issue.linearIssueId,
102
- queueRepairAttempts: issue.queueRepairAttempts + 1,
103
- }));
104
- }
105
- if (isRequestedChangesRunType(runType)) {
106
- return Boolean(this.db.issueSessions.upsertIssueWithLease(lease, {
107
- projectId: issue.projectId,
108
- linearIssueId: issue.linearIssueId,
109
- reviewFixAttempts: issue.reviewFixAttempts + 1,
110
- }));
111
- }
112
- return true;
96
+ // The increments are read-modify-write against the issue row (which may
97
+ // be stale by the time the launch path gets here); on conflict, recompute
98
+ // from the fresh row instead of writing a counter derived from the stale
99
+ // read.
100
+ const buildIncrement = (record) => {
101
+ if (runType === "ci_repair") {
102
+ return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: record.ciRepairAttempts + 1 };
103
+ }
104
+ if (runType === "queue_repair") {
105
+ return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: record.queueRepairAttempts + 1 };
106
+ }
107
+ if (isRequestedChangesRunType(runType)) {
108
+ return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: record.reviewFixAttempts + 1 };
109
+ }
110
+ return undefined;
111
+ };
112
+ const update = buildIncrement(issue);
113
+ if (!update)
114
+ return true;
115
+ const commit = this.db.issueSessions.commitIssueState({
116
+ writer: WRITER,
117
+ lease,
118
+ expectedVersion: issue.version,
119
+ update,
120
+ onConflict: (current) => buildIncrement(current),
121
+ });
122
+ return commit.outcome === "applied";
113
123
  }
114
124
  }
@@ -1,5 +1,6 @@
1
1
  import { buildOperatorRetryEvent } from "./operator-retry-event.js";
2
2
  import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
3
+ const WRITER = "service-issue-actions";
3
4
  export class ServiceIssueActions {
4
5
  config;
5
6
  db;
@@ -76,10 +77,13 @@ export class ServiceIssueActions {
76
77
  dedupeKey: `operator_stop:${issue.linearIssueId}`,
77
78
  });
78
79
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
79
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
80
- projectId: issue.projectId,
81
- linearIssueId: issue.linearIssueId,
82
- factoryState: "awaiting_input",
80
+ this.db.issueSessions.commitIssueState({
81
+ writer: WRITER,
82
+ update: {
83
+ projectId: issue.projectId,
84
+ linearIssueId: issue.linearIssueId,
85
+ factoryState: "awaiting_input",
86
+ },
83
87
  });
84
88
  this.feed.publish({
85
89
  level: "warn",
@@ -111,19 +115,25 @@ export class ServiceIssueActions {
111
115
  lastGitHubFailureSource: issue.lastGitHubFailureSource,
112
116
  });
113
117
  if (retryTarget.runType === "none") {
114
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
115
- projectId: issue.projectId,
116
- linearIssueId: issue.linearIssueId,
117
- factoryState: "done",
118
+ this.db.issueSessions.commitIssueState({
119
+ writer: WRITER,
120
+ update: {
121
+ projectId: issue.projectId,
122
+ linearIssueId: issue.linearIssueId,
123
+ factoryState: "done",
124
+ },
118
125
  });
119
126
  return { issueKey, runType: "none" };
120
127
  }
121
128
  this.appendOperatorRetryEvent(issue, retryTarget.runType);
122
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
123
- projectId: issue.projectId,
124
- linearIssueId: issue.linearIssueId,
125
- factoryState: retryTarget.factoryState,
126
- ...buildManualRetryAttemptReset(retryTarget.runType),
129
+ this.db.issueSessions.commitIssueState({
130
+ writer: WRITER,
131
+ update: {
132
+ projectId: issue.projectId,
133
+ linearIssueId: issue.linearIssueId,
134
+ factoryState: retryTarget.factoryState,
135
+ ...buildManualRetryAttemptReset(retryTarget.runType),
136
+ },
127
137
  });
128
138
  this.feed.publish({
129
139
  level: "info",
@@ -168,22 +178,29 @@ export class ServiceIssueActions {
168
178
  dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
169
179
  });
170
180
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
171
- if (run) {
172
- this.db.issueSessions.finishRunRespectingActiveLease(issue.projectId, issue.linearIssueId, run.id, {
173
- status: "released",
174
- failureReason: options?.reason
175
- ? `Operator closed issue as ${terminalState}: ${options.reason}`
176
- : `Operator closed issue as ${terminalState}`,
181
+ // Operator close is authoritative: the issue terminal write and the run
182
+ // release ride in one transaction, with the run gated on the issue commit.
183
+ this.db.transaction(() => {
184
+ const commit = this.db.issueSessions.commitIssueState({
185
+ writer: WRITER,
186
+ update: {
187
+ projectId: issue.projectId,
188
+ linearIssueId: issue.linearIssueId,
189
+ delegatedToPatchRelay: false,
190
+ factoryState: terminalState,
191
+ activeRunId: null,
192
+ pendingRunType: null,
193
+ pendingRunContextJson: null,
194
+ },
177
195
  });
178
- }
179
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
180
- projectId: issue.projectId,
181
- linearIssueId: issue.linearIssueId,
182
- delegatedToPatchRelay: false,
183
- factoryState: terminalState,
184
- activeRunId: null,
185
- pendingRunType: null,
186
- pendingRunContextJson: null,
196
+ if (run && commit.outcome === "applied") {
197
+ this.db.runs.finishRun(run.id, {
198
+ status: "released",
199
+ failureReason: options?.reason
200
+ ? `Operator closed issue as ${terminalState}: ${options.reason}`
201
+ : `Operator closed issue as ${terminalState}`,
202
+ });
203
+ }
187
204
  });
188
205
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
189
206
  this.feed.publish({