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
@@ -16,6 +16,7 @@ import { LinearIssueProjectionService } from "./linear-issue-projection.js";
16
16
  import { TerminalWakeReconciler } from "./terminal-wake-reconciler.js";
17
17
  const BLOCKED_DEPENDENCY_REFRESH_SUCCESS_BACKOFF_MS = 60_000;
18
18
  const BLOCKED_DEPENDENCY_REFRESH_FAILURE_BACKOFF_MS = 5 * 60_000;
19
+ const WRITER = "idle-reconciliation";
19
20
  export class IdleIssueReconciler {
20
21
  db;
21
22
  config;
@@ -174,12 +175,16 @@ export class IdleIssueReconciler {
174
175
  return;
175
176
  const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
176
177
  if (isDeployTrackingEnabled(project)) {
177
- this.advanceIdleIssue(issue, "deploying", { clearFailureProvenance: true });
178
- this.db.issues.upsertIssue({
179
- projectId: issue.projectId,
180
- linearIssueId: issue.linearIssueId,
181
- deployStartedAt: new Date().toISOString(),
182
- });
178
+ if (this.advanceIdleIssue(issue, "deploying", { clearFailureProvenance: true }) !== "skipped") {
179
+ this.db.issueSessions.commitIssueState({
180
+ writer: WRITER,
181
+ update: {
182
+ projectId: issue.projectId,
183
+ linearIssueId: issue.linearIssueId,
184
+ deployStartedAt: new Date().toISOString(),
185
+ },
186
+ });
187
+ }
183
188
  }
184
189
  else {
185
190
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
@@ -242,33 +247,49 @@ export class IdleIssueReconciler {
242
247
  }
243
248
  }
244
249
  finishDeploy(issue, state) {
245
- this.advanceIdleIssue(issue, state, state === "done" ? { clearFailureProvenance: true } : undefined);
246
- this.db.issues.upsertIssue({
247
- projectId: issue.projectId,
248
- linearIssueId: issue.linearIssueId,
249
- deployStartedAt: null,
250
+ if (this.advanceIdleIssue(issue, state, state === "done" ? { clearFailureProvenance: true } : undefined) === "skipped") {
251
+ return;
252
+ }
253
+ this.db.issueSessions.commitIssueState({
254
+ writer: WRITER,
255
+ update: {
256
+ projectId: issue.projectId,
257
+ linearIssueId: issue.linearIssueId,
258
+ deployStartedAt: null,
259
+ },
250
260
  });
251
261
  }
252
262
  advanceIdleIssue(issue, newState, options) {
253
263
  if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
254
- return;
264
+ return "noop";
255
265
  }
256
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
257
- this.db.issues.upsertIssue({
258
- projectId: issue.projectId,
259
- linearIssueId: issue.linearIssueId,
260
- factoryState: newState,
261
- ...((options?.pendingRunType || newState === "awaiting_queue" || newState === "delegated" || newState === "done")
262
- ? {
263
- pendingRunType: null,
264
- pendingRunContextJson: null,
265
- }
266
- : {}),
267
- ...(options?.clearFailureProvenance
268
- ? { ...CLEARED_FAILURE_PROVENANCE }
269
- : {}),
266
+ const commit = this.db.issueSessions.commitIssueState({
267
+ writer: WRITER,
268
+ expectedVersion: issue.version,
269
+ update: {
270
+ projectId: issue.projectId,
271
+ linearIssueId: issue.linearIssueId,
272
+ factoryState: newState,
273
+ ...((options?.pendingRunType || newState === "awaiting_queue" || newState === "delegated" || newState === "done")
274
+ ? {
275
+ pendingRunType: null,
276
+ pendingRunContextJson: null,
277
+ }
278
+ : {}),
279
+ ...(options?.clearFailureProvenance
280
+ ? { ...CLEARED_FAILURE_PROVENANCE }
281
+ : {}),
282
+ },
283
+ // A writer that landed mid-tick (almost always a webhook) is newer
284
+ // truth than this pass's read; skip and let the next tick re-derive.
285
+ onConflict: () => undefined,
270
286
  });
271
- const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
287
+ if (commit.outcome !== "applied") {
288
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, outcome: commit.outcome }, "Reconciliation: skipped advancing idle issue after a concurrent write");
289
+ return "skipped";
290
+ }
291
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
292
+ const updatedIssue = commit.issue;
272
293
  if (this.syncIssue) {
273
294
  void Promise.resolve(this.syncIssue(updatedIssue)).catch((error) => {
274
295
  this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to sync Linear workflow state after idle reconciliation");
@@ -289,6 +310,7 @@ export class IdleIssueReconciler {
289
310
  // The dispatcher's recordEventAndDispatch in recordWakeEvent already
290
311
  // handles the enqueue when no run is in flight, so no extra poke
291
312
  // is needed here.
313
+ return "applied";
292
314
  }
293
315
  recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
294
316
  const eventType = reactiveWakeEventType(runType);
@@ -395,19 +417,27 @@ export class IdleIssueReconciler {
395
417
  ?? (inferred === "queue_eviction" && failureHeadSha && checkName
396
418
  ? ["queue_eviction", failureHeadSha, checkName].join("::")
397
419
  : null);
398
- this.db.issues.upsertIssue({
399
- projectId: issue.projectId,
400
- linearIssueId: issue.linearIssueId,
401
- lastGitHubFailureSource: inferred,
402
- ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
403
- ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
404
- ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
420
+ // Inference from a stale read must never overwrite provenance a
421
+ // concurrent webhook just recorded — skip on conflict and continue
422
+ // with the fresh row.
423
+ const commit = this.db.issueSessions.commitIssueState({
424
+ writer: WRITER,
425
+ expectedVersion: issue.version,
426
+ update: {
427
+ projectId: issue.projectId,
428
+ linearIssueId: issue.linearIssueId,
429
+ lastGitHubFailureSource: inferred,
430
+ ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
431
+ ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
432
+ ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
433
+ },
434
+ onConflict: () => undefined,
405
435
  });
406
- const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
407
- if (!refreshed)
408
- return issue;
436
+ if (commit.outcome !== "applied") {
437
+ return (commit.outcome === "conflict_skipped" ? commit.issue : undefined) ?? issue;
438
+ }
409
439
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
410
- return refreshed;
440
+ return commit.issue;
411
441
  }
412
442
  async reclassifyStaleBranchFailure(issue) {
413
443
  const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved";
@@ -423,19 +453,24 @@ export class IdleIssueReconciler {
423
453
  const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
424
454
  const failureSignature = issue.lastGitHubFailureSignature
425
455
  ?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
426
- this.db.issues.upsertIssue({
427
- projectId: issue.projectId,
428
- linearIssueId: issue.linearIssueId,
429
- lastGitHubFailureSource: "queue_eviction",
430
- ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
431
- ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
432
- ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
456
+ const commit = this.db.issueSessions.commitIssueState({
457
+ writer: WRITER,
458
+ expectedVersion: issue.version,
459
+ update: {
460
+ projectId: issue.projectId,
461
+ linearIssueId: issue.linearIssueId,
462
+ lastGitHubFailureSource: "queue_eviction",
463
+ ...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
464
+ ...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
465
+ ...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
466
+ },
467
+ onConflict: () => undefined,
433
468
  });
434
- const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
435
- if (!refreshed)
436
- return issue;
469
+ if (commit.outcome !== "applied") {
470
+ return (commit.outcome === "conflict_skipped" ? commit.issue : undefined) ?? issue;
471
+ }
437
472
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
438
- return refreshed;
473
+ return commit.issue;
439
474
  }
440
475
  async inferFailureSourceFromGitHub(issue) {
441
476
  const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
@@ -486,7 +521,8 @@ export class IdleIssueReconciler {
486
521
  const project = this.config.projects.find((p) => p.id === issue.projectId);
487
522
  if (!project?.github?.repoFullName || !issue.prNumber)
488
523
  return;
489
- const snapshot = await fetchPullRequestSnapshot(project.github.repoFullName, issue.prNumber);
524
+ const prNumber = issue.prNumber;
525
+ const snapshot = await fetchPullRequestSnapshot(project.github.repoFullName, prNumber);
490
526
  if (!snapshot.ok) {
491
527
  this.logger.debug({ issueKey: issue.issueKey, error: snapshot.error.message }, "Failed to query GitHub PR state during reconciliation");
492
528
  if (issue.prReviewState === "approved") {
@@ -501,25 +537,42 @@ export class IdleIssueReconciler {
501
537
  const previousHeadSha = issue.prHeadSha;
502
538
  const gateCheckNames = getGateCheckNames(project);
503
539
  const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
504
- this.db.issues.upsertIssue({
505
- projectId: issue.projectId,
506
- linearIssueId: issue.linearIssueId,
507
- ...buildPrStateUpdates(pr, gateCheckStatus, gateCheckNames[0] ?? "verify"),
540
+ const factsCommit = this.db.issueSessions.commitIssueState({
541
+ writer: WRITER,
542
+ update: {
543
+ projectId: issue.projectId,
544
+ linearIssueId: issue.linearIssueId,
545
+ ...buildPrStateUpdates(pr, gateCheckStatus, gateCheckNames[0] ?? "verify"),
546
+ },
508
547
  });
548
+ // Continue the pass with the refreshed row so later version-checked
549
+ // writes don't see our own facts write as a conflict.
550
+ if (factsCommit.outcome === "applied") {
551
+ issue = factsCommit.issue;
552
+ }
509
553
  if (pr.state === "MERGED") {
510
- this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
554
+ this.db.issueSessions.commitIssueState({
555
+ writer: WRITER,
556
+ update: { projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" },
557
+ });
511
558
  const merged = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? { ...issue, prState: "merged" };
512
559
  await this.handleMergedIssue(merged);
513
560
  return;
514
561
  }
515
562
  if (pr.state === "CLOSED") {
516
563
  const closedPrDisposition = resolveClosedPrDisposition(issue);
517
- this.db.issues.upsertIssue({
518
- projectId: issue.projectId,
519
- linearIssueId: issue.linearIssueId,
520
- prState: "closed",
521
- ...buildClosedPrCleanupFields(),
564
+ const closedCommit = this.db.issueSessions.commitIssueState({
565
+ writer: WRITER,
566
+ update: {
567
+ projectId: issue.projectId,
568
+ linearIssueId: issue.linearIssueId,
569
+ prState: "closed",
570
+ ...buildClosedPrCleanupFields(),
571
+ },
522
572
  });
573
+ if (closedCommit.outcome === "applied") {
574
+ issue = closedCommit.issue;
575
+ }
523
576
  if (closedPrDisposition === "done") {
524
577
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
525
578
  this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
@@ -604,7 +657,7 @@ export class IdleIssueReconciler {
604
657
  return;
605
658
  }
606
659
  const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
607
- ? buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
660
+ ? buildBranchUpkeepContext(prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
608
661
  : undefined;
609
662
  this.logger.info({
610
663
  issueKey: issue.issueKey,
@@ -648,7 +701,7 @@ export class IdleIssueReconciler {
648
701
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
649
702
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
650
703
  pendingRunType: reactiveIntent.runType,
651
- pendingRunContext: buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid),
704
+ pendingRunContext: buildBranchUpkeepContext(prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid),
652
705
  });
653
706
  this.feed?.publish({
654
707
  level: "warn",
@@ -683,10 +736,13 @@ export class IdleIssueReconciler {
683
736
  return;
684
737
  }
685
738
  if (isReviewDecisionApproved(pr.reviewDecision)) {
686
- this.db.issues.upsertIssue({
687
- projectId: issue.projectId,
688
- linearIssueId: issue.linearIssueId,
689
- prReviewState: "approved",
739
+ this.db.issueSessions.commitIssueState({
740
+ writer: WRITER,
741
+ update: {
742
+ projectId: issue.projectId,
743
+ linearIssueId: issue.linearIssueId,
744
+ prReviewState: "approved",
745
+ },
690
746
  });
691
747
  if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
692
748
  const options = hasFailureProvenance(issue) ? { clearFailureProvenance: true } : undefined;
@@ -1,4 +1,5 @@
1
1
  import { execCommand } from "./utils.js";
2
+ const WRITER = "implementation-outcome-policy";
2
3
  export class ImplementationOutcomePolicy {
3
4
  config;
4
5
  db;
@@ -92,7 +93,10 @@ export class ImplementationOutcomePolicy {
92
93
  }
93
94
  }
94
95
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
95
- const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
96
+ const updated = this.withHeldLease(projectId, linearIssueId, (lease) => {
97
+ const commit = this.db.issueSessions.commitIssueState({ writer: WRITER, lease, update: params });
98
+ return commit.outcome === "applied" ? commit.issue : undefined;
99
+ });
96
100
  if (updated === undefined) {
97
101
  this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
98
102
  }
@@ -3,6 +3,9 @@ export class ImmediateIssueSessionProjectionInvalidator {
3
3
  deps;
4
4
  batchDepth = 0;
5
5
  pendingProjections = new Map();
6
+ // Captured once: the guard must stay a single integer compare on the hot
7
+ // read path and be OFF in production.
8
+ strictMidBatchReads = process.env.NODE_ENV !== "production";
6
9
  constructor(deps) {
7
10
  this.deps = deps;
8
11
  }
@@ -18,6 +21,12 @@ export class ImmediateIssueSessionProjectionInvalidator {
18
21
  }
19
22
  }
20
23
  }
24
+ assertNotMidBatch(context) {
25
+ if (this.batchDepth > 0 && this.strictMidBatchReads) {
26
+ throw new Error(`Issue-session projection read mid-batch (${context}): the projection is stale until the `
27
+ + "batch flushes. Read it before batchIssueSessionProjections() or after it returns.");
28
+ }
29
+ }
21
30
  issueChanged(issue, options) {
22
31
  const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
23
32
  this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
@@ -1,6 +1,7 @@
1
1
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
3
  import { computeLinearActivityKey } from "./linear-activity-key.js";
4
+ const WRITER = "linear-agent-session-client";
4
5
  export class LinearAgentSessionClient {
5
6
  config;
6
7
  db;
@@ -22,11 +23,15 @@ export class LinearAgentSessionClient {
22
23
  if (!recoveredAgentSessionId)
23
24
  return issue;
24
25
  this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
25
- return this.db.issues.upsertIssue({
26
- projectId: issue.projectId,
27
- linearIssueId: issue.linearIssueId,
28
- agentSessionId: recoveredAgentSessionId,
26
+ const commit = this.db.issueSessions.commitIssueState({
27
+ writer: WRITER,
28
+ update: {
29
+ projectId: issue.projectId,
30
+ linearIssueId: issue.linearIssueId,
31
+ agentSessionId: recoveredAgentSessionId,
32
+ },
29
33
  });
34
+ return commit.outcome === "applied" ? commit.issue : issue;
30
35
  }
31
36
  async emitActivity(issue, content, options) {
32
37
  const syncedIssue = this.ensureAgentSessionIssue(issue);
@@ -48,10 +53,13 @@ export class LinearAgentSessionClient {
48
53
  ...(ephemeral ? { ephemeral: true } : {}),
49
54
  });
50
55
  if (activityKey) {
51
- this.db.issues.upsertIssue({
52
- projectId: syncedIssue.projectId,
53
- linearIssueId: syncedIssue.linearIssueId,
54
- lastLinearActivityKey: activityKey,
56
+ this.db.issueSessions.commitIssueState({
57
+ writer: WRITER,
58
+ update: {
59
+ projectId: syncedIssue.projectId,
60
+ linearIssueId: syncedIssue.linearIssueId,
61
+ lastLinearActivityKey: activityKey,
62
+ },
55
63
  });
56
64
  }
57
65
  }
@@ -1,3 +1,4 @@
1
+ const WRITER = "linear-issue-projection";
1
2
  export class LinearIssueProjectionService {
2
3
  db;
3
4
  linearProvider;
@@ -51,17 +52,20 @@ export function upsertLinearIssueProjection(db, projectId, liveIssue) {
51
52
  childLinearIssueId: liveIssue.id,
52
53
  parentLinearIssueId: liveIssue.parentId ?? null,
53
54
  });
54
- db.issues.upsertIssue({
55
- projectId,
56
- linearIssueId: liveIssue.id,
57
- ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
58
- ...(liveIssue.title ? { title: liveIssue.title } : {}),
59
- ...(liveIssue.description ? { description: liveIssue.description } : {}),
60
- ...(liveIssue.url ? { url: liveIssue.url } : {}),
61
- ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
62
- ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
63
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
64
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
55
+ db.issueSessions.commitIssueState({
56
+ writer: WRITER,
57
+ update: {
58
+ projectId,
59
+ linearIssueId: liveIssue.id,
60
+ ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
61
+ ...(liveIssue.title ? { title: liveIssue.title } : {}),
62
+ ...(liveIssue.description ? { description: liveIssue.description } : {}),
63
+ ...(liveIssue.url ? { url: liveIssue.url } : {}),
64
+ ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
65
+ ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
66
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
67
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
68
+ },
65
69
  });
66
70
  }
67
71
  export function replaceIssueDependenciesFromLinearIssue(db, projectId, liveIssue) {
@@ -3,6 +3,7 @@ import { isClosedPrState } from "./pr-state.js";
3
3
  import { derivePrDisplayContext } from "./pr-display-context.js";
4
4
  import { deriveIssueStatusNote } from "./status-note.js";
5
5
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
6
+ const WRITER = "linear-status-comment-sync";
6
7
  export async function syncVisibleStatusComment(params) {
7
8
  const { db, issue, linear, logger, trackedIssue, options } = params;
8
9
  try {
@@ -13,10 +14,13 @@ export async function syncVisibleStatusComment(params) {
13
14
  body,
14
15
  });
15
16
  if (result.id !== issue.statusCommentId) {
16
- db.issues.upsertIssue({
17
- projectId: issue.projectId,
18
- linearIssueId: issue.linearIssueId,
19
- statusCommentId: result.id,
17
+ db.issueSessions.commitIssueState({
18
+ writer: WRITER,
19
+ update: {
20
+ projectId: issue.projectId,
21
+ linearIssueId: issue.linearIssueId,
22
+ statusCommentId: result.id,
23
+ },
20
24
  });
21
25
  }
22
26
  }
@@ -2,6 +2,7 @@ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState
2
2
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
3
  import { isCompletedLinearState } from "./pr-state.js";
4
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
5
+ const WRITER = "linear-workflow-state-sync";
5
6
  export async function syncActiveWorkflowState(params) {
6
7
  const { db, issue, linear, trackedIssue, options, project } = params;
7
8
  const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
@@ -103,11 +104,14 @@ async function syncCompletedLinearState(params) {
103
104
  refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
104
105
  }
105
106
  function refreshCachedLinearState(db, issue, stateName, stateType) {
106
- db.issues.upsertIssue({
107
- projectId: issue.projectId,
108
- linearIssueId: issue.linearIssueId,
109
- ...(stateName ? { currentLinearState: stateName } : {}),
110
- ...(stateType ? { currentLinearStateType: stateType } : {}),
107
+ db.issueSessions.commitIssueState({
108
+ writer: WRITER,
109
+ update: {
110
+ projectId: issue.projectId,
111
+ linearIssueId: issue.linearIssueId,
112
+ ...(stateName ? { currentLinearState: stateName } : {}),
113
+ ...(stateType ? { currentLinearStateType: stateType } : {}),
114
+ },
111
115
  });
112
116
  }
113
117
  function shouldAutoAdvanceLinearState(issue) {
@@ -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) {