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
@@ -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({
@@ -3,6 +3,7 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
4
4
  import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
5
5
  import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
6
+ const WRITER = "service-startup-recovery";
6
7
  export class ServiceStartupRecovery {
7
8
  config;
8
9
  db;
@@ -30,13 +31,17 @@ export class ServiceStartupRecovery {
30
31
  ? issue
31
32
  : (() => {
32
33
  const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
33
- return recoveredAgentSessionId
34
- ? this.db.issues.upsertIssue({
34
+ if (!recoveredAgentSessionId)
35
+ return issue;
36
+ const commit = this.db.issueSessions.commitIssueState({
37
+ writer: WRITER,
38
+ update: {
35
39
  projectId: issue.projectId,
36
40
  linearIssueId: issue.linearIssueId,
37
41
  agentSessionId: recoveredAgentSessionId,
38
- })
39
- : issue;
42
+ },
43
+ });
44
+ return commit.outcome === "applied" ? commit.issue : issue;
40
45
  })();
41
46
  if (!syncedIssue.agentSessionId) {
42
47
  continue;
@@ -54,7 +59,7 @@ export class ServiceStartupRecovery {
54
59
  }
55
60
  async recoverDelegatedIssueStateFromLinear() {
56
61
  await this.discoverDelegatedIssuesFromLinear();
57
- for (const issue of this.db.issues.listIssues()) {
62
+ for (let issue of this.db.issues.listIssues()) {
58
63
  if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
59
64
  continue;
60
65
  }
@@ -71,6 +76,9 @@ export class ServiceStartupRecovery {
71
76
  continue;
72
77
  }
73
78
  upsertLinearIssueProjection(this.db, issue.projectId, liveIssue);
79
+ // The projection write bumped the issue version; continue with the
80
+ // fresh row so the recovery commit below doesn't self-conflict.
81
+ issue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
74
82
  const delegated = liveIssue.delegateId === installation.actorId;
75
83
  if (issue.delegatedToPatchRelay !== delegated) {
76
84
  appendDelegationObservedEvent(this.db, {
@@ -116,24 +124,36 @@ export class ServiceStartupRecovery {
116
124
  const shouldRecoverReactivePrWork = delegated
117
125
  && issue.prNumber !== undefined
118
126
  && reactiveIntent !== undefined;
119
- const updated = this.db.issues.upsertIssue({
120
- projectId: issue.projectId,
121
- linearIssueId: issue.linearIssueId,
122
- delegatedToPatchRelay: delegated,
123
- ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
124
- ...(liveIssue.title ? { title: liveIssue.title } : {}),
125
- ...(liveIssue.description ? { description: liveIssue.description } : {}),
126
- ...(liveIssue.url ? { url: liveIssue.url } : {}),
127
- ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
128
- ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
129
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
130
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
131
- ...(shouldRecoverPausedLocalWork
132
- ? { factoryState: "delegated" }
133
- : shouldRecoverReactivePrWork
134
- ? { factoryState: reactiveIntent.compatibilityFactoryState }
135
- : {}),
127
+ const commit = this.db.issueSessions.commitIssueState({
128
+ writer: WRITER,
129
+ expectedVersion: issue.version,
130
+ update: {
131
+ projectId: issue.projectId,
132
+ linearIssueId: issue.linearIssueId,
133
+ delegatedToPatchRelay: delegated,
134
+ ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
135
+ ...(liveIssue.title ? { title: liveIssue.title } : {}),
136
+ ...(liveIssue.description ? { description: liveIssue.description } : {}),
137
+ ...(liveIssue.url ? { url: liveIssue.url } : {}),
138
+ ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
139
+ ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
140
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
141
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
142
+ ...(shouldRecoverPausedLocalWork
143
+ ? { factoryState: "delegated" }
144
+ : shouldRecoverReactivePrWork
145
+ ? { factoryState: reactiveIntent.compatibilityFactoryState }
146
+ : {}),
147
+ },
148
+ // The recovery decision was derived from the row read at loop start
149
+ // plus stale PR facts; a concurrent writer (webhook, another recovery
150
+ // pass) invalidates it. Skip — reconciliation re-derives shortly.
151
+ onConflict: () => undefined,
136
152
  });
153
+ if (commit.outcome !== "applied") {
154
+ continue;
155
+ }
156
+ const updated = commit.issue;
137
157
  if (!shouldRecoverPausedLocalWork && !shouldRecoverReactivePrWork) {
138
158
  continue;
139
159
  }
@@ -215,20 +235,26 @@ export class ServiceStartupRecovery {
215
235
  upsertDiscoveredDelegatedIssue(project, liveIssue) {
216
236
  upsertLinearIssueProjection(this.db, project.id, liveIssue);
217
237
  const existing = this.db.issues.getIssue(project.id, liveIssue.id);
218
- const updated = this.db.issues.upsertIssue({
219
- projectId: project.id,
220
- linearIssueId: liveIssue.id,
221
- delegatedToPatchRelay: true,
222
- factoryState: existing?.factoryState ?? "delegated",
223
- ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
224
- ...(liveIssue.title ? { title: liveIssue.title } : {}),
225
- ...(liveIssue.description ? { description: liveIssue.description } : {}),
226
- ...(liveIssue.url ? { url: liveIssue.url } : {}),
227
- ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
228
- ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
229
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
230
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
238
+ const commit = this.db.issueSessions.commitIssueState({
239
+ writer: WRITER,
240
+ update: {
241
+ projectId: project.id,
242
+ linearIssueId: liveIssue.id,
243
+ delegatedToPatchRelay: true,
244
+ factoryState: existing?.factoryState ?? "delegated",
245
+ ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
246
+ ...(liveIssue.title ? { title: liveIssue.title } : {}),
247
+ ...(liveIssue.description ? { description: liveIssue.description } : {}),
248
+ ...(liveIssue.url ? { url: liveIssue.url } : {}),
249
+ ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
250
+ ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
251
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
252
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
253
+ },
231
254
  });
255
+ if (commit.outcome !== "applied")
256
+ return;
257
+ const updated = commit.issue;
232
258
  const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
233
259
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
234
260
  if (!hasPendingWake && unresolvedBlockers === 0) {
package/dist/telemetry.js CHANGED
@@ -78,6 +78,15 @@ export class OperatorFeedTelemetrySink {
78
78
  };
79
79
  }
80
80
  return undefined;
81
+ case "state.write_conflict":
82
+ return {
83
+ level: "warn",
84
+ kind: "workflow",
85
+ ...(event.issueKey ? { issueKey: event.issueKey } : {}),
86
+ ...(event.projectId ? { projectId: event.projectId } : {}),
87
+ status: "state_write_conflict",
88
+ summary: `Issue-state write conflict (${event.writer}): expected v${event.expectedVersion ?? "none"}, found v${event.actualVersion ?? "none"} — ${event.resolution.replaceAll("_", " ")}`,
89
+ };
81
90
  case "health.invariant":
82
91
  return {
83
92
  level: event.status === "observed" ? "warn" : "info",
@@ -15,14 +15,31 @@ export class TerminalWakeReconciler {
15
15
  && issue.pendingRunType === undefined) {
16
16
  continue;
17
17
  }
18
- this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
19
- this.db.issues.upsertIssue({
18
+ const pendingEvents = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { pendingOnly: true });
19
+ const clearUpdate = {
20
20
  projectId: issue.projectId,
21
21
  linearIssueId: issue.linearIssueId,
22
22
  pendingRunType: null,
23
23
  pendingRunContextJson: null,
24
+ };
25
+ const commit = this.db.issueSessions.commitIssueState({
26
+ writer: "terminal-wake-reconciler",
27
+ expectedVersion: issue.version,
28
+ update: clearUpdate,
29
+ // Only clear if the issue is still terminal on the fresh row.
30
+ onConflict: (current) => (TERMINAL_STATES.has(current.factoryState) ? clearUpdate : undefined),
24
31
  });
25
- this.logger.info({ issueKey: issue.issueKey, factoryState: issue.factoryState }, "Reconciliation: cleared stale terminal wake");
32
+ if (commit.outcome !== "applied")
33
+ continue;
34
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
35
+ // Audit trail: record what was dropped so "why didn't this retry?"
36
+ // is answerable later.
37
+ this.logger.info({
38
+ issueKey: issue.issueKey,
39
+ factoryState: issue.factoryState,
40
+ droppedPendingRunType: issue.pendingRunType,
41
+ droppedEventTypes: pendingEvents.map((event) => event.eventType),
42
+ }, "Reconciliation: cleared stale terminal wake");
26
43
  }
27
44
  }
28
45
  }
@@ -3,6 +3,7 @@ import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js"
3
3
  import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
4
  import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
5
5
  import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
6
+ const WRITER = "agent-session-handler";
6
7
  const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
7
8
  "action",
8
9
  "elicitation",
@@ -172,19 +173,28 @@ export class AgentSessionHandler {
172
173
  catch (error) {
173
174
  this.logger.warn({ issueKey: params.trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
174
175
  }
175
- this.db.runs.finishRun(params.activeRun.id, {
176
- status: "released",
177
- threadId: params.activeRun.threadId,
178
- turnId: params.activeRun.turnId,
179
- failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
180
- });
181
176
  }
182
- this.db.issueSessions.upsertIssueRespectingActiveLease(params.project.id, issueId, {
183
- projectId: params.project.id,
184
- linearIssueId: issueId,
185
- activeRunId: null,
186
- factoryState: "awaiting_input",
187
- agentSessionId: sessionId,
177
+ // The stop signal is a user fact: the issue slot clear and the run
178
+ // release ride in one transaction, with the run gated on the issue commit.
179
+ this.db.transaction(() => {
180
+ const commit = this.db.issueSessions.commitIssueState({
181
+ writer: WRITER,
182
+ update: {
183
+ projectId: params.project.id,
184
+ linearIssueId: issueId,
185
+ activeRunId: null,
186
+ factoryState: "awaiting_input",
187
+ agentSessionId: sessionId,
188
+ },
189
+ });
190
+ if (commit.outcome === "applied" && params.activeRun?.threadId && params.activeRun.turnId) {
191
+ this.db.runs.finishRun(params.activeRun.id, {
192
+ status: "released",
193
+ threadId: params.activeRun.threadId,
194
+ turnId: params.activeRun.turnId,
195
+ failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
196
+ });
197
+ }
188
198
  });
189
199
  this.db.issueSessions.appendIssueSessionEvent({
190
200
  projectId: params.project.id,
@@ -1,4 +1,5 @@
1
1
  import { emitTelemetry, noopTelemetry } from "../telemetry.js";
2
+ const WRITER = "dependency-readiness-handler";
2
3
  export class DependencyReadinessHandler {
3
4
  db;
4
5
  wakeDispatcher;
@@ -41,11 +42,14 @@ export class DependencyReadinessHandler {
41
42
  if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
42
43
  && issue.activeRunId === undefined
43
44
  && !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
44
- this.db.issues.upsertIssue({
45
- projectId,
46
- linearIssueId: dependent.linearIssueId,
47
- pendingRunType: null,
48
- pendingRunContextJson: null,
45
+ this.db.issueSessions.commitIssueState({
46
+ writer: WRITER,
47
+ update: {
48
+ projectId,
49
+ linearIssueId: dependent.linearIssueId,
50
+ pendingRunType: null,
51
+ pendingRunContextJson: null,
52
+ },
49
53
  });
50
54
  }
51
55
  continue;
@@ -72,11 +76,14 @@ export class DependencyReadinessHandler {
72
76
  continue;
73
77
  }
74
78
  if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
75
- this.db.issues.upsertIssue({
76
- projectId,
77
- linearIssueId: dependent.linearIssueId,
78
- pendingRunType: null,
79
- pendingRunContextJson: null,
79
+ this.db.issueSessions.commitIssueState({
80
+ writer: WRITER,
81
+ update: {
82
+ projectId,
83
+ linearIssueId: dependent.linearIssueId,
84
+ pendingRunType: null,
85
+ pendingRunContextJson: null,
86
+ },
80
87
  });
81
88
  }
82
89
  const dispatchedRunType = this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
@@ -7,6 +7,7 @@ import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
7
7
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
8
8
  import { planIssueWebhookWorkflow } from "./issue-webhook-workflow-planner.js";
9
9
  import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
10
+ const WRITER = "desired-stage-recorder";
10
11
  export class DesiredStageRecorder {
11
12
  db;
12
13
  linearProvider;
@@ -84,8 +85,14 @@ export class DesiredStageRecorder {
84
85
  : workflowPlan.effectiveRunRelease.reason
85
86
  : undefined;
86
87
  const dirtyWorktreePayload = releaseWorktreeStatus ? dirtyWorktreeEventPayload(releaseWorktreeStatus) : undefined;
87
- const commitIssueUpdate = () => {
88
- const record = this.db.issues.upsertIssue({
88
+ const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.project.id, normalizedIssue.id);
89
+ // Webhook intake projection: the fields are facts carried by the webhook
90
+ // payload and the hydrated Linear issue, applied unconditionally (the
91
+ // active lease still gates the write, matching the previous semantics).
92
+ const issueCommit = this.db.issueSessions.commitIssueState({
93
+ writer: WRITER,
94
+ ...(activeLease ? { lease: activeLease } : {}),
95
+ update: {
89
96
  projectId: params.project.id,
90
97
  linearIssueId: normalizedIssue.id,
91
98
  ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
@@ -103,20 +110,32 @@ export class DesiredStageRecorder {
103
110
  ...linkedPrAdoption?.issueUpdates,
104
111
  delegatedToPatchRelay: delegated,
105
112
  ...workflowPlan.resolvedIssueUpdate,
106
- });
113
+ },
114
+ });
115
+ let issue;
116
+ if (issueCommit.outcome === "applied") {
117
+ issue = issueCommit.issue;
107
118
  if (workflowPlan.effectiveRunRelease.release && activeRun && releaseReason) {
108
119
  this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: releaseReason });
109
120
  }
110
- return record;
111
- };
112
- const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.project.id, normalizedIssue.id);
113
- const issue = activeLease
114
- ? this.db.issueSessions.withIssueSessionLease(params.project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.issues.upsertIssue({
115
- projectId: params.project.id,
116
- linearIssueId: normalizedIssue.id,
117
- ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
118
- }))
119
- : this.db.transaction(commitIssueUpdate);
121
+ }
122
+ else if (existingIssue) {
123
+ issue = existingIssue;
124
+ }
125
+ else {
126
+ const fallbackCommit = this.db.issueSessions.commitIssueState({
127
+ writer: WRITER,
128
+ update: {
129
+ projectId: params.project.id,
130
+ linearIssueId: normalizedIssue.id,
131
+ ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
132
+ },
133
+ });
134
+ if (fallbackCommit.outcome !== "applied") {
135
+ return { issue: undefined, wakeRunType: undefined, delegated };
136
+ }
137
+ issue = fallbackCommit.issue;
138
+ }
120
139
  const previousParentIssueId = existingIssue?.parentLinearIssueId;
121
140
  const currentParentIssueId = issue.parentLinearIssueId;
122
141
  const wasResolved = isResolvedLinearState(existingIssue?.currentLinearStateType, existingIssue?.currentLinearState);
@@ -1,4 +1,5 @@
1
1
  import { TERMINAL_STATES } from "../factory-state.js";
2
+ const WRITER = "issue-removal-handler";
2
3
  export class IssueRemovalHandler {
3
4
  db;
4
5
  feed;
@@ -13,27 +14,42 @@ export class IssueRemovalHandler {
13
14
  const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.projectId, params.issue.id);
14
15
  const commitRemoval = () => {
15
16
  if (removedIssue?.activeRunId) {
16
- const run = this.db.runs.getRunById(removedIssue.activeRunId);
17
- if (run) {
18
- this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
19
- }
20
- return this.db.issues.upsertIssue({
17
+ const removedRunId = removedIssue.activeRunId;
18
+ const run = this.db.runs.getRunById(removedRunId);
19
+ const update = {
21
20
  projectId: params.projectId,
22
21
  linearIssueId: params.issue.id,
23
22
  activeRunId: null,
24
23
  pendingRunType: null,
25
24
  factoryState: "failed",
25
+ };
26
+ const commit = this.db.issueSessions.commitIssueState({
27
+ writer: WRITER,
28
+ expectedVersion: removedIssue.version,
29
+ ...(activeLease ? { lease: activeLease } : {}),
30
+ update,
31
+ onConflict: (current) => (current.activeRunId === removedRunId ? update : undefined),
26
32
  });
33
+ if (run && commit.outcome === "applied") {
34
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
35
+ }
36
+ return;
27
37
  }
28
38
  if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
29
- return this.db.issues.upsertIssue({
39
+ const update = {
30
40
  projectId: params.projectId,
31
41
  linearIssueId: params.issue.id,
32
42
  pendingRunType: null,
33
43
  factoryState: "failed",
44
+ };
45
+ this.db.issueSessions.commitIssueState({
46
+ writer: WRITER,
47
+ expectedVersion: removedIssue.version,
48
+ ...(activeLease ? { lease: activeLease } : {}),
49
+ update,
50
+ onConflict: (current) => (TERMINAL_STATES.has(current.factoryState) ? undefined : update),
34
51
  });
35
52
  }
36
- return removedIssue;
37
53
  };
38
54
  if (removedIssue?.activeRunId) {
39
55
  const run = this.db.runs.getRunById(removedIssue.activeRunId);
@@ -41,12 +57,7 @@ export class IssueRemovalHandler {
41
57
  await params.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
42
58
  }
43
59
  }
44
- if (activeLease) {
45
- this.db.issueSessions.withIssueSessionLease(params.projectId, params.issue.id, activeLease.leaseId, commitRemoval);
46
- }
47
- else {
48
- commitRemoval();
49
- }
60
+ commitRemoval();
50
61
  this.db.issueSessions.appendIssueSessionEvent({
51
62
  projectId: params.projectId,
52
63
  linearIssueId: params.issue.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.75.2",
3
+ "version": "0.76.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {