patchrelay 0.75.0 → 0.75.2

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.
@@ -2,24 +2,33 @@ export class SerialWorkQueue {
2
2
  onDequeue;
3
3
  logger;
4
4
  getKey;
5
+ options;
5
6
  items = [];
6
7
  queuedKeys = new Set();
7
8
  pending = false;
8
- constructor(onDequeue, logger, getKey) {
9
+ constructor(onDequeue, logger, getKey, options = {}) {
9
10
  this.onDequeue = onDequeue;
10
11
  this.logger = logger;
11
12
  this.getKey = getKey;
13
+ this.options = options;
12
14
  }
13
15
  enqueue(item, options) {
16
+ this.enqueueEntry({ item, attempt: 0 }, options);
17
+ }
18
+ size() {
19
+ return this.items.length;
20
+ }
21
+ enqueueEntry(entry, options) {
22
+ const { item } = entry;
14
23
  const key = this.getKey?.(item);
15
24
  if (key && this.queuedKeys.has(key)) {
16
25
  return;
17
26
  }
18
27
  if (options?.priority) {
19
- this.items.unshift(item);
28
+ this.items.unshift(entry);
20
29
  }
21
30
  else {
22
- this.items.push(item);
31
+ this.items.push(entry);
23
32
  }
24
33
  if (key) {
25
34
  this.queuedKeys.add(key);
@@ -31,22 +40,45 @@ export class SerialWorkQueue {
31
40
  });
32
41
  }
33
42
  }
43
+ scheduleRetry(entry, delayMs) {
44
+ const key = this.getKey?.(entry.item);
45
+ if (key && this.queuedKeys.has(key)) {
46
+ return;
47
+ }
48
+ if (key) {
49
+ this.queuedKeys.add(key);
50
+ }
51
+ const timer = setTimeout(() => {
52
+ if (key) {
53
+ this.queuedKeys.delete(key);
54
+ }
55
+ this.enqueueEntry(entry);
56
+ }, delayMs);
57
+ timer.unref?.();
58
+ }
34
59
  async drain() {
35
60
  while (this.items.length > 0) {
36
- const next = this.items.shift();
37
- if (next === undefined) {
61
+ const entry = this.items.shift();
62
+ if (entry === undefined) {
38
63
  continue;
39
64
  }
40
- const key = this.getKey?.(next);
65
+ const key = this.getKey?.(entry.item);
41
66
  if (key) {
42
67
  this.queuedKeys.delete(key);
43
68
  }
44
69
  try {
45
- await this.onDequeue(next);
70
+ await this.onDequeue(entry.item);
46
71
  }
47
72
  catch (error) {
48
73
  const err = error instanceof Error ? error : new Error(String(error));
49
- this.logger.error({ item: next, error: err.message, stack: err.stack }, "Queue item processing failed");
74
+ const nextAttempt = entry.attempt + 1;
75
+ const retry = this.options.retryOnError?.(err, entry.item, nextAttempt);
76
+ if (retry) {
77
+ this.logger[retry.logLevel ?? "warn"]({ item: entry.item, error: err.message, attempt: nextAttempt, retryDelayMs: retry.delayMs }, retry.message ?? "Queue item processing failed; retrying");
78
+ this.scheduleRetry({ item: entry.item, attempt: nextAttempt }, retry.delayMs);
79
+ continue;
80
+ }
81
+ this.logger.error({ item: entry.item, error: err.message, stack: err.stack }, "Queue item processing failed");
50
82
  }
51
83
  }
52
84
  this.pending = false;
@@ -1,7 +1,11 @@
1
1
  import { SerialWorkQueue } from "./service-queue.js";
2
+ import { retrySqliteLockedQueueFailure } from "./queue-failure-policy.js";
2
3
  const ISSUE_KEY_DELIMITER = "::";
3
4
  const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
4
5
  const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
6
+ const DEFAULT_MAX_ACTIVE_ISSUE_RUNS = 4;
7
+ const DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS = 5_000;
8
+ const EVENT_LOOP_MONITOR_INTERVAL_MS = 1_000;
5
9
  function makeIssueQueueKey(item) {
6
10
  return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
7
11
  }
@@ -19,6 +23,9 @@ export class ServiceRuntime {
19
23
  githubAppAuthError;
20
24
  startupError;
21
25
  reconcileTimer;
26
+ eventLoopMonitorTimer;
27
+ eventLoopMonitorExpectedAt = 0;
28
+ eventLoopLagMs = 0;
22
29
  reconcileInProgress = false;
23
30
  constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
24
31
  this.codex = codex;
@@ -27,11 +34,23 @@ export class ServiceRuntime {
27
34
  this.readyIssueSource = readyIssueSource;
28
35
  this.options = options;
29
36
  this.webhookQueue = new SerialWorkQueue((eventId) => webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
30
- this.issueQueue = new SerialWorkQueue((item) => issueProcessor.processIssue(item), logger, makeIssueQueueKey);
37
+ this.issueQueue = new SerialWorkQueue((item) => this.processIssueWithCapacity(item, issueProcessor), logger, makeIssueQueueKey, {
38
+ retryOnError: (error, _item, attempt) => {
39
+ if (error instanceof IssueRunCapacityFullError) {
40
+ return {
41
+ delayMs: this.getIssueRunCapacityRetryDelayMs(),
42
+ logLevel: "debug",
43
+ message: "Issue run capacity is full; keeping item queued for retry",
44
+ };
45
+ }
46
+ return retrySqliteLockedQueueFailure(error, attempt);
47
+ },
48
+ });
31
49
  }
32
50
  async start() {
33
51
  try {
34
52
  await this.codex.start();
53
+ this.startEventLoopMonitor();
35
54
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
36
55
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
37
56
  }
@@ -48,6 +67,7 @@ export class ServiceRuntime {
48
67
  async stop() {
49
68
  this.ready = false;
50
69
  this.clearBackgroundReconcile();
70
+ this.clearEventLoopMonitor();
51
71
  await this.codex.stop();
52
72
  }
53
73
  enqueueWebhookEvent(eventId, options) {
@@ -69,10 +89,29 @@ export class ServiceRuntime {
69
89
  codexStarted: this.codex.isStarted(),
70
90
  linearConnected: this.linearConnected,
71
91
  githubAppAuthHealthy: this.githubAppAuthHealthy,
92
+ eventLoopLagMs: this.eventLoopLagMs,
72
93
  ...(this.githubAppAuthError ? { githubAppAuthError: this.githubAppAuthError } : {}),
73
94
  ...(this.startupError ? { startupError: this.startupError } : {}),
74
95
  };
75
96
  }
97
+ startEventLoopMonitor() {
98
+ this.clearEventLoopMonitor();
99
+ this.eventLoopMonitorExpectedAt = Date.now() + EVENT_LOOP_MONITOR_INTERVAL_MS;
100
+ const timer = setInterval(() => {
101
+ const now = Date.now();
102
+ this.eventLoopLagMs = Math.max(0, now - this.eventLoopMonitorExpectedAt);
103
+ this.eventLoopMonitorExpectedAt = now + EVENT_LOOP_MONITOR_INTERVAL_MS;
104
+ }, EVENT_LOOP_MONITOR_INTERVAL_MS);
105
+ timer.unref?.();
106
+ this.eventLoopMonitorTimer = timer;
107
+ }
108
+ clearEventLoopMonitor() {
109
+ if (this.eventLoopMonitorTimer !== undefined) {
110
+ clearInterval(this.eventLoopMonitorTimer);
111
+ this.eventLoopMonitorTimer = undefined;
112
+ }
113
+ this.eventLoopLagMs = 0;
114
+ }
76
115
  scheduleBackgroundReconcile() {
77
116
  this.clearBackgroundReconcile();
78
117
  const timer = setTimeout(() => {
@@ -113,6 +152,35 @@ export class ServiceRuntime {
113
152
  }
114
153
  }
115
154
  }
155
+ getMaxActiveIssueRuns() {
156
+ const configured = this.options.maxActiveIssueRuns ?? DEFAULT_MAX_ACTIVE_ISSUE_RUNS;
157
+ return Math.max(1, Math.floor(configured));
158
+ }
159
+ getIssueRunCapacityRetryDelayMs() {
160
+ const configured = this.options.issueRunCapacityRetryDelayMs ?? DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS;
161
+ return Math.max(1, Math.floor(configured));
162
+ }
163
+ getActiveIssueRunCount() {
164
+ return Math.max(0, this.readyIssueSource.countActiveIssueRuns?.() ?? 0);
165
+ }
166
+ async processIssueWithCapacity(item, processor) {
167
+ const activeIssueRuns = this.getActiveIssueRunCount();
168
+ const maxActiveIssueRuns = this.getMaxActiveIssueRuns();
169
+ if (activeIssueRuns >= maxActiveIssueRuns) {
170
+ throw new IssueRunCapacityFullError(activeIssueRuns, maxActiveIssueRuns);
171
+ }
172
+ await processor.processIssue(item);
173
+ }
174
+ }
175
+ class IssueRunCapacityFullError extends Error {
176
+ activeIssueRuns;
177
+ maxActiveIssueRuns;
178
+ constructor(activeIssueRuns, maxActiveIssueRuns) {
179
+ super(`active issue run capacity is full (${activeIssueRuns}/${maxActiveIssueRuns})`);
180
+ this.activeIssueRuns = activeIssueRuns;
181
+ this.maxActiveIssueRuns = maxActiveIssueRuns;
182
+ this.name = "IssueRunCapacityFullError";
183
+ }
116
184
  }
117
185
  function promiseWithTimeout(promise, timeoutMs, label) {
118
186
  return new Promise((resolve, reject) => {
@@ -2,13 +2,16 @@ import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
2
  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
+ import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
5
6
  export class ServiceStartupRecovery {
7
+ config;
6
8
  db;
7
9
  linearProvider;
8
10
  linearSync;
9
11
  enqueueIssue;
10
12
  logger;
11
- constructor(db, linearProvider, linearSync, enqueueIssue, logger) {
13
+ constructor(config, db, linearProvider, linearSync, enqueueIssue, logger) {
14
+ this.config = config;
12
15
  this.db = db;
13
16
  this.linearProvider = linearProvider;
14
17
  this.linearSync = linearSync;
@@ -20,6 +23,9 @@ export class ServiceStartupRecovery {
20
23
  if (issue.factoryState === "done") {
21
24
  continue;
22
25
  }
26
+ if (!issue.activeRunId) {
27
+ continue;
28
+ }
23
29
  const syncedIssue = issue.agentSessionId
24
30
  ? issue
25
31
  : (() => {
@@ -35,7 +41,11 @@ export class ServiceStartupRecovery {
35
41
  if (!syncedIssue.agentSessionId) {
36
42
  continue;
37
43
  }
38
- const activeRun = syncedIssue.activeRunId ? this.db.runs.getRunById(syncedIssue.activeRunId) : undefined;
44
+ const activeRunId = syncedIssue.activeRunId;
45
+ if (!activeRunId) {
46
+ continue;
47
+ }
48
+ const activeRun = this.db.runs.getRunById(activeRunId);
39
49
  if (!activeRun) {
40
50
  continue;
41
51
  }
@@ -43,6 +53,7 @@ export class ServiceStartupRecovery {
43
53
  }
44
54
  }
45
55
  async recoverDelegatedIssueStateFromLinear() {
56
+ await this.discoverDelegatedIssuesFromLinear();
46
57
  for (const issue of this.db.issues.listIssues()) {
47
58
  if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
48
59
  continue;
@@ -59,17 +70,7 @@ export class ServiceStartupRecovery {
59
70
  if (!liveIssue) {
60
71
  continue;
61
72
  }
62
- this.db.issues.replaceIssueDependencies({
63
- projectId: issue.projectId,
64
- linearIssueId: issue.linearIssueId,
65
- blockers: liveIssue.blockedBy.map((blocker) => ({
66
- blockerLinearIssueId: blocker.id,
67
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
68
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
69
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
70
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
71
- })),
72
- });
73
+ upsertLinearIssueProjection(this.db, issue.projectId, liveIssue);
73
74
  const delegated = liveIssue.delegateId === installation.actorId;
74
75
  if (issue.delegatedToPatchRelay !== delegated) {
75
76
  appendDelegationObservedEvent(this.db, {
@@ -169,6 +170,86 @@ export class ServiceStartupRecovery {
169
170
  }
170
171
  }
171
172
  }
173
+ async discoverDelegatedIssuesFromLinear() {
174
+ for (const project of this.config.projects) {
175
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
176
+ if (!installation?.actorId) {
177
+ continue;
178
+ }
179
+ const linear = await this.linearProvider.forProject(project.id).catch(() => undefined);
180
+ if (!linear?.listIssuesDelegatedTo) {
181
+ continue;
182
+ }
183
+ const liveIssues = await linear.listIssuesDelegatedTo({
184
+ delegateId: installation.actorId,
185
+ teamIds: project.linearTeamIds,
186
+ }).catch((error) => {
187
+ this.logger.warn({
188
+ projectId: project.id,
189
+ error: error instanceof Error ? error.message : String(error),
190
+ }, "Failed to discover delegated Linear issues during startup recovery");
191
+ return [];
192
+ });
193
+ for (const liveIssue of liveIssues) {
194
+ if (!this.shouldRecoverDiscoveredIssue(project, liveIssue, installation.actorId)) {
195
+ continue;
196
+ }
197
+ const existing = this.db.issues.getIssue(project.id, liveIssue.id);
198
+ if (existing) {
199
+ continue;
200
+ }
201
+ this.upsertDiscoveredDelegatedIssue(project, liveIssue);
202
+ }
203
+ }
204
+ }
205
+ shouldRecoverDiscoveredIssue(project, liveIssue, actorId) {
206
+ if (liveIssue.delegateId !== actorId)
207
+ return false;
208
+ if (liveIssue.stateType === "completed" || liveIssue.stateType === "canceled")
209
+ return false;
210
+ if (project.linearTeamIds.length > 0 && (!liveIssue.teamId || !project.linearTeamIds.includes(liveIssue.teamId))) {
211
+ return false;
212
+ }
213
+ return true;
214
+ }
215
+ upsertDiscoveredDelegatedIssue(project, liveIssue) {
216
+ upsertLinearIssueProjection(this.db, project.id, liveIssue);
217
+ 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 } : {}),
231
+ });
232
+ const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
233
+ const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
234
+ if (!hasPendingWake && unresolvedBlockers === 0) {
235
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, liveIssue.id, {
236
+ projectId: project.id,
237
+ linearIssueId: liveIssue.id,
238
+ eventType: "delegated",
239
+ dedupeKey: `delegated:${liveIssue.id}`,
240
+ });
241
+ }
242
+ if (this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id)) {
243
+ this.enqueueIssue(project.id, liveIssue.id);
244
+ }
245
+ this.logger.info({
246
+ issueKey: updated.issueKey,
247
+ projectId: project.id,
248
+ unresolvedBlockers,
249
+ }, unresolvedBlockers === 0
250
+ ? "Discovered delegated Linear issue during startup recovery and queued implementation"
251
+ : "Discovered delegated blocked Linear issue during startup recovery");
252
+ }
172
253
  appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
173
254
  const eventType = reactiveWakeEventType(runType);
174
255
  const dedupeKey = runType === "queue_repair" || runType === "ci_repair"
package/dist/service.js CHANGED
@@ -14,6 +14,7 @@ import { ServiceStartupRecovery } from "./service-startup-recovery.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
15
  import { WebhookHandler } from "./webhook-handler.js";
16
16
  import { acceptIncomingWebhook } from "./service-webhooks.js";
17
+ import { runWebhookEventRetention } from "./event-retention.js";
17
18
  import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
18
19
  import { AgentInputService } from "./agent-input-service.js";
19
20
  import { CodexFollowupIntentClassifier } from "./followup-intent.js";
@@ -36,6 +37,7 @@ export class PatchRelayService {
36
37
  issueActions;
37
38
  startupRecovery;
38
39
  trackedIssueListQuery;
40
+ eventRetentionTimer;
39
41
  constructor(config, db, codex, linearProvider, logger, configPath) {
40
42
  this.config = config;
41
43
  this.db = db;
@@ -67,7 +69,10 @@ export class PatchRelayService {
67
69
  leaseRelease = (projectId, issueId) => this.orchestrator.leaseService.release(projectId, issueId);
68
70
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput, telemetry);
69
71
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, dispatcher, logger, codex, this.feed);
70
- const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
72
+ const runtime = new ServiceRuntime(codex, logger, this.orchestrator, {
73
+ listIssuesReadyForExecution: () => db.listIssuesReadyForExecution(),
74
+ countActiveIssueRuns: () => db.runs.listActiveRuns().length,
75
+ }, this.webhookHandler, {
71
76
  processIssue: async (item) => {
72
77
  await this.orchestrator.run(item);
73
78
  },
@@ -77,7 +82,7 @@ export class PatchRelayService {
77
82
  this.queryService = new IssueQueryService(db, codex, this.orchestrator);
78
83
  this.runtime = runtime;
79
84
  this.issueActions = new ServiceIssueActions(config, db, agentInput, codex, runtime, this.feed, logger);
80
- this.startupRecovery = new ServiceStartupRecovery(db, this.linearProvider, this.orchestrator.linearSync, (projectId, issueId) => runtime.enqueueIssue(projectId, issueId), logger);
85
+ this.startupRecovery = new ServiceStartupRecovery(config, db, this.linearProvider, this.orchestrator.linearSync, (projectId, issueId) => runtime.enqueueIssue(projectId, issueId), logger);
81
86
  this.trackedIssueListQuery = new TrackedIssueListQuery(db);
82
87
  // Optional GitHub App token management for bot identity
83
88
  const ghAppCredentials = resolveGitHubAppCredentials();
@@ -178,6 +183,7 @@ export class PatchRelayService {
178
183
  });
179
184
  }
180
185
  await this.runtime.start();
186
+ this.scheduleEventRetention(60_000);
181
187
  void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
182
188
  const msg = error instanceof Error ? error.message : String(error);
183
189
  this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
@@ -188,6 +194,10 @@ export class PatchRelayService {
188
194
  });
189
195
  }
190
196
  async stop() {
197
+ if (this.eventRetentionTimer !== undefined) {
198
+ clearTimeout(this.eventRetentionTimer);
199
+ this.eventRetentionTimer = undefined;
200
+ }
191
201
  this.githubAppTokenManager?.stop();
192
202
  await this.runtime.stop();
193
203
  }
@@ -277,6 +287,37 @@ export class PatchRelayService {
277
287
  getReadiness() {
278
288
  return this.runtime.getReadiness();
279
289
  }
290
+ scheduleEventRetention(delayMs = 24 * 60 * 60 * 1000) {
291
+ if (this.eventRetentionTimer !== undefined) {
292
+ clearTimeout(this.eventRetentionTimer);
293
+ }
294
+ const timer = setTimeout(() => {
295
+ void this.runEventRetentionMaintenance();
296
+ }, delayMs);
297
+ timer.unref?.();
298
+ this.eventRetentionTimer = timer;
299
+ }
300
+ async runEventRetentionMaintenance() {
301
+ try {
302
+ const result = await runWebhookEventRetention({
303
+ db: this.db,
304
+ config: this.config,
305
+ });
306
+ if (result.deleted > 0 || result.archived > 0 || result.remaining > 0) {
307
+ this.logger.info(result, "Webhook event retention maintenance completed");
308
+ }
309
+ if (this.config.database.wal) {
310
+ const checkpoint = this.db.runWalCheckpoint("PASSIVE");
311
+ this.logger.debug({ checkpoint }, "SQLite WAL checkpoint completed");
312
+ }
313
+ }
314
+ catch (error) {
315
+ this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Webhook event retention maintenance failed");
316
+ }
317
+ finally {
318
+ this.scheduleEventRetention();
319
+ }
320
+ }
280
321
  listTrackedIssues() {
281
322
  return this.trackedIssueListQuery.listTrackedIssues();
282
323
  }
@@ -0,0 +1,28 @@
1
+ import { TERMINAL_STATES } from "./factory-state.js";
2
+ export class TerminalWakeReconciler {
3
+ db;
4
+ logger;
5
+ constructor(db, logger) {
6
+ this.db = db;
7
+ this.logger = logger;
8
+ }
9
+ reconcile() {
10
+ for (const issue of this.db.issues.listIssues()) {
11
+ if (!TERMINAL_STATES.has(issue.factoryState) || issue.activeRunId !== undefined) {
12
+ continue;
13
+ }
14
+ if (!this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId)
15
+ && issue.pendingRunType === undefined) {
16
+ continue;
17
+ }
18
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
19
+ this.db.issues.upsertIssue({
20
+ projectId: issue.projectId,
21
+ linearIssueId: issue.linearIssueId,
22
+ pendingRunType: null,
23
+ pendingRunContextJson: null,
24
+ });
25
+ this.logger.info({ issueKey: issue.issueKey, factoryState: issue.factoryState }, "Reconciliation: cleared stale terminal wake");
26
+ }
27
+ }
28
+ }
@@ -1,3 +1,4 @@
1
+ import { replaceIssueDependenciesFromLinearIssue } from "../linear-issue-projection.js";
1
2
  import { mergeIssueMetadata } from "./decision-helpers.js";
2
3
  /**
3
4
  * Brings the local dependency / parent-link state for `issue` up to date.
@@ -24,17 +25,7 @@ export async function syncIssueDependencies(db, linearProvider, projectId, issue
24
25
  }
25
26
  }
26
27
  if (source.relationsKnown) {
27
- db.issues.replaceIssueDependencies({
28
- projectId,
29
- linearIssueId: source.id,
30
- blockers: source.blockedBy.map((blocker) => ({
31
- blockerLinearIssueId: blocker.id,
32
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
33
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
34
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
35
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
36
- })),
37
- });
28
+ replaceIssueDependenciesFromLinearIssue(db, projectId, source);
38
29
  }
39
30
  db.issues.replaceIssueParentLink({
40
31
  projectId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.75.0",
3
+ "version": "0.75.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {