patchrelay 0.67.2 → 0.68.1

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.
@@ -31,7 +31,7 @@ export class RunFinalizer {
31
31
  db;
32
32
  logger;
33
33
  linearSync;
34
- enqueueIssue;
34
+ wakeDispatcher;
35
35
  withHeldLease;
36
36
  releaseLease;
37
37
  appendWakeEventWithLease;
@@ -40,11 +40,11 @@ export class RunFinalizer {
40
40
  completionCheck;
41
41
  publicationRecap;
42
42
  feed;
43
- constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, publicationRecap, feed) {
43
+ constructor(db, logger, linearSync, wakeDispatcher, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, publicationRecap, feed) {
44
44
  this.db = db;
45
45
  this.logger = logger;
46
46
  this.linearSync = linearSync;
47
- this.enqueueIssue = enqueueIssue;
47
+ this.wakeDispatcher = wakeDispatcher;
48
48
  this.withHeldLease = withHeldLease;
49
49
  this.releaseLease = releaseLease;
50
50
  this.appendWakeEventWithLease = appendWakeEventWithLease;
@@ -164,9 +164,21 @@ export class RunFinalizer {
164
164
  patchId: identity.patchId,
165
165
  }, "Recorded last-published change identity after run completion");
166
166
  }
167
- clearProgressAndRelease(run) {
167
+ // Single owner of "clear Linear progress + release lease + drain pending
168
+ // wake". Every run-end path goes through here. Routes the release through
169
+ // the WakeDispatcher so a wake that landed during the run is picked up
170
+ // even on failure paths (the previous implementation only drained on the
171
+ // success path). Failure and completion-check paths publish their own
172
+ // more-specific operator-feed event before getting here, so the
173
+ // dispatcher's "deferred_follow_up_queued" notification is opt-in via
174
+ // `publishDeferredFollowUp` and used only by the success path.
175
+ clearProgressAndRelease(run, options) {
168
176
  this.linearSync.clearProgress(run.id);
169
- this.releaseLease(run.projectId, run.linearIssueId);
177
+ this.wakeDispatcher.releaseRunAndDispatch({
178
+ run,
179
+ ...(options?.issueKey ? { issueKey: options.issueKey } : {}),
180
+ ...(options?.publishDeferredFollowUp ? { publishDeferredFollowUp: true } : {}),
181
+ });
170
182
  }
171
183
  // Plan §4.4: finalize a run that was superseded mid-flight. The
172
184
  // status row was already moved to `superseded` by the trigger
@@ -199,26 +211,6 @@ export class RunFinalizer {
199
211
  ...(run.projectId ? { projectId: run.projectId } : {}),
200
212
  });
201
213
  }
202
- enqueuePendingWakeIfPresent(params) {
203
- const wake = this.db.issueSessions.peekIssueSessionWake(params.run.projectId, params.run.linearIssueId);
204
- if (!wake)
205
- return undefined;
206
- this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
207
- this.feed?.publish({
208
- level: "info",
209
- kind: "stage",
210
- issueKey: params.issueKey,
211
- projectId: params.run.projectId,
212
- stage: wake.runType,
213
- status: "deferred_follow_up_queued",
214
- summary: `${wake.runType} queued after ${params.run.runType} released authority`,
215
- ...(wake.wakeReason ? { detail: `wake reason: ${wake.wakeReason}` } : {}),
216
- });
217
- return {
218
- runType: wake.runType,
219
- ...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
220
- };
221
- }
222
214
  publishTurnEvent(params) {
223
215
  this.feed?.publish({
224
216
  level: params.level,
@@ -257,11 +249,11 @@ export class RunFinalizer {
257
249
  });
258
250
  void this.linearSync.emitActivity(issue, params.activity, { ephemeral: true });
259
251
  void this.linearSync.syncSession(issue);
260
- this.linearSync.clearProgress(params.run.id);
261
- if (params.enqueue) {
262
- this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
263
- }
264
- this.releaseLease(params.run.projectId, params.run.linearIssueId);
252
+ // releaseRunAndDispatch always drains any pending wake — the
253
+ // explicit `params.enqueue` flag is no longer needed because the
254
+ // dispatcher peeks the wake itself and only enqueues when one
255
+ // exists. Keeping the parameter would be redundant.
256
+ this.clearProgressAndRelease(params.run);
265
257
  }
266
258
  async finalizeCompletedRun(params) {
267
259
  const { run, issue, thread, threadId } = params;
@@ -341,6 +333,7 @@ export class RunFinalizer {
341
333
  syncFailureOutcome: (event) => this.syncFailureOutcome(event),
342
334
  syncCompletionCheckOutcome: (event) => this.syncCompletionCheckOutcome(event),
343
335
  clearProgressAndRelease: (releaseRun) => this.clearProgressAndRelease(releaseRun),
336
+ wakeDispatcher: this.wakeDispatcher,
344
337
  });
345
338
  return;
346
339
  }
@@ -396,8 +389,7 @@ export class RunFinalizer {
396
389
  });
397
390
  if (!completed) {
398
391
  this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion writes after losing issue-session lease");
399
- this.linearSync.clearProgress(run.id);
400
- this.releaseLease(run.projectId, run.linearIssueId);
392
+ this.clearProgressAndRelease(run);
401
393
  return;
402
394
  }
403
395
  if (postRunFollowUp) {
@@ -435,9 +427,10 @@ export class RunFinalizer {
435
427
  void this.linearSync.emitActivity(updatedIssue, linearActivity);
436
428
  }
437
429
  void this.linearSync.syncSession(updatedIssue);
438
- this.enqueuePendingWakeIfPresent({ run, issueKey: updatedIssue.issueKey });
439
- this.linearSync.clearProgress(run.id);
440
- this.releaseLease(run.projectId, run.linearIssueId);
430
+ this.clearProgressAndRelease(run, {
431
+ ...(updatedIssue.issueKey ? { issueKey: updatedIssue.issueKey } : {}),
432
+ publishDeferredFollowUp: true,
433
+ });
441
434
  }
442
435
  async recoverFailedImplementationRun(params) {
443
436
  const freshIssue = this.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;
@@ -466,6 +459,7 @@ export class RunFinalizer {
466
459
  syncFailureOutcome: (event) => this.syncFailureOutcome(event),
467
460
  syncCompletionCheckOutcome: (event) => this.syncCompletionCheckOutcome(event),
468
461
  clearProgressAndRelease: (releaseRun) => this.clearProgressAndRelease(releaseRun),
462
+ wakeDispatcher: this.wakeDispatcher,
469
463
  });
470
464
  return true;
471
465
  }
@@ -3,6 +3,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
3
  import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
4
4
  import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
5
5
  import { configureGitHubBotAuthForWorktree } from "./github-worktree-auth.js";
6
+ import { sanitizeDiagnosticText } from "./utils.js";
6
7
  function slugify(value) {
7
8
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
8
9
  }
@@ -15,6 +16,28 @@ function shouldCompactThread(issue, threadGeneration, context) {
15
16
  && (threadGeneration ?? 0) >= 4
16
17
  && followUpCount >= 4;
17
18
  }
19
+ function compactGoalText(value, maxLength = 600) {
20
+ const normalized = value.replace(/\s+/g, " ").trim();
21
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
22
+ }
23
+ function extractIssueSection(description, heading) {
24
+ if (!description)
25
+ return undefined;
26
+ const headingLine = `## ${heading}`.toLowerCase();
27
+ const lines = description.split(/\r?\n/);
28
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === headingLine);
29
+ if (start === -1)
30
+ return undefined;
31
+ const end = lines.findIndex((line, index) => index > start && /^##\s+/.test(line));
32
+ const body = lines.slice(start + 1, end === -1 ? undefined : end).join("\n").trim();
33
+ return body && body.length > 0 ? body : undefined;
34
+ }
35
+ export function buildInitialImplementationGoal(issue) {
36
+ const title = issue.title?.trim() || `Complete ${issue.issueKey ?? issue.linearIssueId}`;
37
+ const description = issue.description?.trim();
38
+ const goal = extractIssueSection(description, "Goal");
39
+ return compactGoalText(goal ? `${title}. ${goal}` : title);
40
+ }
18
41
  export function shouldReuseIssueThread(params) {
19
42
  return Boolean(params.existingThreadId) && !params.compactThread && params.resumeThread;
20
43
  }
@@ -125,6 +148,8 @@ export class RunLauncher {
125
148
  let threadId;
126
149
  let turnId;
127
150
  let parentThreadId;
151
+ let createdThreadForRun = false;
152
+ const firstThreadForIssue = !params.issue.threadId;
128
153
  try {
129
154
  await this.worktreeManager.ensureIssueWorktree(params.project.repoPath, params.project.worktreeRoot, params.worktreePath, params.branchName, { allowExistingOutsideRoot: params.issue.branchName !== undefined });
130
155
  if (params.botIdentity) {
@@ -157,6 +182,7 @@ export class RunLauncher {
157
182
  else {
158
183
  const thread = await this.codex.startThread({ cwd: params.worktreePath });
159
184
  threadId = thread.id;
185
+ createdThreadForRun = true;
160
186
  this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
161
187
  }
162
188
  try {
@@ -169,6 +195,7 @@ export class RunLauncher {
169
195
  this.logger.info({ issueKey: params.issue.issueKey, staleThreadId: threadId }, "Thread is stale, retrying with fresh thread");
170
196
  const thread = await this.codex.startThread({ cwd: params.worktreePath });
171
197
  threadId = thread.id;
198
+ createdThreadForRun = true;
172
199
  this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
173
200
  const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
174
201
  turnId = turn.turnId;
@@ -177,6 +204,9 @@ export class RunLauncher {
177
204
  throw turnError;
178
205
  }
179
206
  }
207
+ if (createdThreadForRun && firstThreadForIssue && params.runType === "implementation") {
208
+ await this.setInitialImplementationGoal(threadId, params.issue);
209
+ }
180
210
  params.assertLaunchLease(params.run, "after starting the Codex turn");
181
211
  return { threadId, turnId, ...(parentThreadId ? { parentThreadId } : {}) };
182
212
  }
@@ -204,4 +234,22 @@ export class RunLauncher {
204
234
  throw error;
205
235
  }
206
236
  }
237
+ async setInitialImplementationGoal(threadId, issue) {
238
+ const goalSetter = this.codex.setThreadGoal;
239
+ if (typeof goalSetter !== "function") {
240
+ return;
241
+ }
242
+ const objective = buildInitialImplementationGoal(issue);
243
+ try {
244
+ await goalSetter.call(this.codex, { threadId, objective, status: "active" });
245
+ this.logger.info({ issueKey: issue.issueKey, threadId }, "Set Codex thread goal for implementation run");
246
+ }
247
+ catch (error) {
248
+ this.logger.warn({
249
+ issueKey: issue.issueKey,
250
+ threadId,
251
+ error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
252
+ }, "Failed to set Codex thread goal for implementation run");
253
+ }
254
+ }
207
255
  }
@@ -18,6 +18,7 @@ import { RunNotificationHandler } from "./run-notification-handler.js";
18
18
  import { RunReconciler } from "./run-reconciler.js";
19
19
  import { RunRecoveryService } from "./run-recovery-service.js";
20
20
  import { RunWakePlanner } from "./run-wake-planner.js";
21
+ import { WakeDispatcher } from "./wake-dispatcher.js";
21
22
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
22
23
  import { classifyIssue } from "./issue-class.js";
23
24
  import { buildIssueTriageHash, IssueTriageService } from "./issue-triage.js";
@@ -45,9 +46,6 @@ export class RunOrchestrator {
45
46
  codex;
46
47
  linearProvider;
47
48
  enqueueIssue;
48
- logger;
49
- feed;
50
- configPath;
51
49
  worktreeManager;
52
50
  mainBranchHealthMonitor;
53
51
  /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
@@ -55,6 +53,9 @@ export class RunOrchestrator {
55
53
  idleReconciler;
56
54
  linearSync;
57
55
  workerId = `patchrelay:${process.pid}`;
56
+ // Exposed so the WakeDispatcher (constructed in service.ts) can call
57
+ // release on this same lease service. Kept on the orchestrator because
58
+ // its construction depends on Codex thread access.
58
59
  leaseService;
59
60
  runFinalizer;
60
61
  runLauncher;
@@ -85,12 +86,37 @@ export class RunOrchestrator {
85
86
  };
86
87
  activeSessionLeases;
87
88
  botIdentity;
88
- constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed, configPath) {
89
+ wakeDispatcher;
90
+ logger;
91
+ feed;
92
+ configPath;
93
+ constructor(config, db, codex, linearProvider, enqueueIssue, wakeDispatcherOrLogger, loggerOrFeed, feedOrConfigPath, configPathOrUndefined) {
89
94
  this.config = config;
90
95
  this.db = db;
91
96
  this.codex = codex;
92
97
  this.linearProvider = linearProvider;
93
98
  this.enqueueIssue = enqueueIssue;
99
+ // Backward-compat: tests pass `(config, db, codex, lp, enqueue, logger, feed?, configPath?)`
100
+ // (no dispatcher). Production passes `(..., enqueue, dispatcher, logger, feed?, configPath?)`.
101
+ let logger;
102
+ let feed;
103
+ let configPath;
104
+ if (wakeDispatcherOrLogger instanceof WakeDispatcher) {
105
+ this.wakeDispatcher = wakeDispatcherOrLogger;
106
+ logger = loggerOrFeed;
107
+ feed = feedOrConfigPath;
108
+ configPath = configPathOrUndefined;
109
+ }
110
+ else {
111
+ logger = wakeDispatcherOrLogger;
112
+ feed = loggerOrFeed;
113
+ configPath = feedOrConfigPath;
114
+ // Construct a dispatcher with a stub releaseLease — the real one
115
+ // gets wired below once the lease service exists. The stub is
116
+ // never called before the wiring completes because the run()
117
+ // loop is the only consumer of releaseRunAndDispatch.
118
+ this.wakeDispatcher = new WakeDispatcher(db, enqueueIssue, (projectId, linearIssueId) => this.leaseService?.release(projectId, linearIssueId), logger, feed);
119
+ }
94
120
  this.logger = logger;
95
121
  this.feed = feed;
96
122
  this.configPath = configPath;
@@ -103,21 +129,19 @@ export class RunOrchestrator {
103
129
  this.completionCheck = new CompletionCheckService(codex, logger);
104
130
  this.publicationRecap = new PublicationRecapService(codex, logger);
105
131
  this.issueTriage = new IssueTriageService(codex, logger);
106
- this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, this.publicationRecap, feed);
132
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.wakeDispatcher, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, this.publicationRecap, feed);
107
133
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
108
134
  this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
109
135
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
110
136
  this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
111
137
  this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
112
138
  this.runWakePlanner = new RunWakePlanner(db);
113
- this.idleReconciler = new IdleIssueReconciler(db, config, {
114
- enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
115
- }, logger, feed);
116
- this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, (projectId, issueId) => this.enqueueIssue(projectId, issueId), logger, feed);
139
+ this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed);
140
+ this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, this.wakeDispatcher, logger, feed);
117
141
  this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
118
142
  this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
119
143
  advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
120
- enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
144
+ wakeDispatcher: this.wakeDispatcher,
121
145
  }, logger, feed);
122
146
  }
123
147
  async refreshCodexRuntimeConfig() {
@@ -223,21 +247,40 @@ export class RunOrchestrator {
223
247
  async run(item) {
224
248
  await this.refreshCodexRuntimeConfig();
225
249
  const project = this.config.projects.find((p) => p.id === item.projectId);
226
- if (!project)
250
+ if (!project) {
251
+ this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "project_not_configured" }, "Skipped issue run: project missing from config");
227
252
  return;
253
+ }
254
+ // Each early-return below logs `{ issueKey, reason }` so the
255
+ // operator-feed and log streams can explain why an issue with a
256
+ // pending wake didn't actually run. The original incident
257
+ // (LSR-495) was undiagnosable because these guards were silent.
228
258
  if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
259
+ this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "lease_held_locally" }, "Skipped issue run: another in-process call still holds the lease");
229
260
  return;
230
261
  }
231
262
  const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
232
- if (!initialIssue || initialIssue.activeRunId !== undefined)
263
+ if (!initialIssue) {
264
+ this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "issue_missing" }, "Skipped issue run: issue row not found");
265
+ return;
266
+ }
267
+ if (initialIssue.activeRunId !== undefined) {
268
+ this.logger.info({ issueKey: initialIssue.issueKey, projectId: item.projectId, reason: "active_run_present", activeRunId: initialIssue.activeRunId }, "Skipped issue run: an active run is already in flight");
233
269
  return;
270
+ }
234
271
  const issue = await this.classifyTrackedIssue(initialIssue);
235
- if (!issue || issue.activeRunId !== undefined)
272
+ if (!issue) {
273
+ this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "classification_dropped_issue" }, "Skipped issue run: classification returned no issue");
274
+ return;
275
+ }
276
+ if (issue.activeRunId !== undefined) {
277
+ this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "active_run_present_post_classify", activeRunId: issue.activeRunId }, "Skipped issue run: an active run appeared during classification");
236
278
  return;
279
+ }
237
280
  const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
238
281
  const leaseId = this.leaseService.acquire(item.projectId, item.issueId);
239
282
  if (!leaseId) {
240
- this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId }, "Skipped run because another worker holds the session lease");
283
+ this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_acquire_failed" }, "Skipped issue run: another worker holds the session lease");
241
284
  return;
242
285
  }
243
286
  if (issue.prState === "merged") {
@@ -248,6 +291,7 @@ export class RunOrchestrator {
248
291
  const wakeIssue = this.materializeLegacyPendingWake(issue, { projectId: item.projectId, linearIssueId: item.issueId, leaseId });
249
292
  const wake = this.resolveRunWake(wakeIssue);
250
293
  if (!wake) {
294
+ this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "no_wake_derivable" }, "Skipped issue run: no actionable wake derivable from pending events");
251
295
  this.leaseService.release(item.projectId, item.issueId);
252
296
  return;
253
297
  }
package/dist/service.js CHANGED
@@ -9,6 +9,7 @@ import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSig
9
9
  import { ServiceRuntime } from "./service-runtime.js";
10
10
  import { ServiceIssueActions } from "./service-issue-actions.js";
11
11
  import { ServiceStartupRecovery } from "./service-startup-recovery.js";
12
+ import { WakeDispatcher } from "./wake-dispatcher.js";
12
13
  import { WebhookHandler } from "./webhook-handler.js";
13
14
  import { acceptIncomingWebhook } from "./service-webhooks.js";
14
15
  import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
@@ -41,9 +42,20 @@ export class PatchRelayService {
41
42
  let enqueueIssue = () => {
42
43
  throw new Error("Service runtime enqueueIssue is not initialized");
43
44
  };
44
- this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, this.configPath);
45
- this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
46
- this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, codex, this.feed);
45
+ let leaseRelease = () => {
46
+ throw new Error("WakeDispatcher releaseLease is not yet bound");
47
+ };
48
+ // The dispatcher owns every "append event + maybe enqueue" and every
49
+ // "release run + drain pending wake" call. See src/wake-dispatcher.ts
50
+ // for why. Both `enqueueIssue` and `leaseRelease` are late-bound — the
51
+ // runtime owns the queue, and the lease service lives inside the
52
+ // orchestrator (its construction depends on the Codex client). All
53
+ // downstream consumers receive this single dispatcher instance.
54
+ const dispatcher = new WakeDispatcher(db, (projectId, issueId) => enqueueIssue(projectId, issueId), (projectId, issueId) => leaseRelease(projectId, issueId), logger, this.feed);
55
+ this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), dispatcher, logger, this.feed, this.configPath);
56
+ leaseRelease = (projectId, issueId) => this.orchestrator.leaseService.release(projectId, issueId);
57
+ this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed);
58
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, dispatcher, logger, codex, this.feed);
47
59
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
48
60
  processIssue: async (item) => {
49
61
  await this.orchestrator.run(item);
@@ -0,0 +1,121 @@
1
+ // Single owner of "append a session event and tell the orchestrator
2
+ // something might be runnable", and of "release a finished run so the
3
+ // next wake fires." Until this existed, 8+ call sites each made their
4
+ // own decision about whether to call `enqueueIssue`. A missed enqueue
5
+ // (lease race, in-memory queue cleared by restart) left events orphaned
6
+ // for hours — we lost 6.5h on LSR-495 to exactly this.
7
+ //
8
+ // Idempotency comes from two layers:
9
+ // - `issue_session_events.dedupe_key` dedupes the event itself.
10
+ // - `SerialWorkQueue` dedupes by issue key inside the worker process.
11
+ //
12
+ // Long-running scopes (the idle reconciler) can call `withTick` to
13
+ // dedupe enqueues within that scope — every call into the dispatcher
14
+ // during the callback contributes to the same Set, so a single
15
+ // reconcile pass produces at most one enqueue per issue even when
16
+ // many sub-passes detect the same wake. The `enqueuedThisTick` option
17
+ // on individual methods is for callers that thread their own Set.
18
+ export class WakeDispatcher {
19
+ db;
20
+ enqueueIssue;
21
+ releaseLease;
22
+ logger;
23
+ feed;
24
+ currentTick;
25
+ constructor(db, enqueueIssue, releaseLease, logger, feed) {
26
+ this.db = db;
27
+ this.enqueueIssue = enqueueIssue;
28
+ this.releaseLease = releaseLease;
29
+ this.logger = logger;
30
+ this.feed = feed;
31
+ }
32
+ // Scope the next enqueue calls inside `fn` to a single dedupe Set.
33
+ // Nested ticks reuse the outermost Set so deeply nested helpers do
34
+ // not silently lose dedupe.
35
+ async withTick(fn) {
36
+ if (this.currentTick)
37
+ return fn();
38
+ this.currentTick = new Set();
39
+ try {
40
+ return await fn();
41
+ }
42
+ finally {
43
+ this.currentTick = undefined;
44
+ }
45
+ }
46
+ // Append a session event and dispatch the issue if a wake is derivable
47
+ // and no run is currently in flight. Returns the runType the next run
48
+ // would have, or undefined if the event is non-actionable / no wake
49
+ // exists / a run is already running (the finalizer will drain it).
50
+ recordEventAndDispatch(projectId, linearIssueId, event, options) {
51
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
52
+ projectId,
53
+ linearIssueId,
54
+ ...event,
55
+ });
56
+ // Honour the active tick scope (set via withTick) so callers nested
57
+ // inside a reconcile pass automatically dedupe without threading
58
+ // the Set through every helper signature.
59
+ return this.dispatchIfWakePending(projectId, linearIssueId, options ?? (this.currentTick ? { enqueuedThisTick: this.currentTick } : undefined));
60
+ }
61
+ // "Make sure the orchestrator looks at this issue, if anything is worth
62
+ // looking at." Used by the idle reconciler safety net for orphan
63
+ // recovery, by dependency-readiness flows that don't append a new
64
+ // event but want to poke, and by the stack-coordination fan-out that
65
+ // sets the legacy `pending_run_type` column on the issue. Suppressed
66
+ // when an active run is in flight — the run finalizer owns the
67
+ // post-run drain via releaseRunAndDispatch.
68
+ dispatchIfWakePending(projectId, linearIssueId, options) {
69
+ const issue = this.db.issues.getIssue(projectId, linearIssueId);
70
+ if (issue?.activeRunId !== undefined)
71
+ return undefined;
72
+ const wake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
73
+ // Fall back to the legacy pending_run_type column. The orchestrator
74
+ // materializes it into a real event at run time, but the poke still
75
+ // needs to happen now so the orchestrator gets called at all.
76
+ const runType = wake?.runType ?? issue?.pendingRunType;
77
+ if (!runType)
78
+ return undefined;
79
+ const tick = options?.enqueuedThisTick ?? this.currentTick;
80
+ const key = `${projectId}:${linearIssueId}`;
81
+ if (tick?.has(key)) {
82
+ return runType;
83
+ }
84
+ tick?.add(key);
85
+ this.enqueueIssue(projectId, linearIssueId);
86
+ return runType;
87
+ }
88
+ // Release the lease for a finished run, then drain any wake that
89
+ // landed during the run. The single owner of "run is over, what's
90
+ // next?". Lease is released BEFORE the dispatch so the orchestrator's
91
+ // lease guard succeeds on dequeue. Every code path that ends a run
92
+ // must go through here, not bare releaseLease.
93
+ //
94
+ // The optional `publishDeferredFollowUp` flag is for callers that
95
+ // want the "deferred_follow_up_queued" operator-feed event emitted
96
+ // here (success path of the run finalizer). Failure / completion-
97
+ // check paths publish their own more-specific event and pass false.
98
+ releaseRunAndDispatch(params) {
99
+ this.releaseLease(params.run.projectId, params.run.linearIssueId);
100
+ const wake = this.db.issueSessions.peekIssueSessionWake(params.run.projectId, params.run.linearIssueId);
101
+ if (!wake)
102
+ return undefined;
103
+ this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
104
+ if (params.publishDeferredFollowUp) {
105
+ this.feed?.publish({
106
+ level: "info",
107
+ kind: "stage",
108
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
109
+ projectId: params.run.projectId,
110
+ stage: wake.runType,
111
+ status: "deferred_follow_up_queued",
112
+ summary: `${wake.runType} queued after ${params.run.runType} released authority`,
113
+ ...(wake.wakeReason ? { detail: `wake reason: ${wake.wakeReason}` } : {}),
114
+ });
115
+ }
116
+ return {
117
+ runType: wake.runType,
118
+ ...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
119
+ };
120
+ }
121
+ }
@@ -11,12 +11,12 @@ import { DesiredStageRecorder } from "./webhooks/desired-stage-recorder.js";
11
11
  import { IssueRemovalHandler } from "./webhooks/issue-removal-handler.js";
12
12
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
13
13
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
14
+ import { WakeDispatcher } from "./wake-dispatcher.js";
14
15
  export class WebhookHandler {
15
16
  config;
16
17
  db;
17
18
  linearProvider;
18
19
  codex;
19
- enqueueIssue;
20
20
  logger;
21
21
  feed;
22
22
  installationHandler;
@@ -27,22 +27,29 @@ export class WebhookHandler {
27
27
  contextLoader;
28
28
  dependencyReadinessHandler;
29
29
  linearSync;
30
- constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
30
+ wakeDispatcher;
31
+ constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed) {
31
32
  this.config = config;
32
33
  this.db = db;
33
34
  this.linearProvider = linearProvider;
34
35
  this.codex = codex;
35
- this.enqueueIssue = enqueueIssue;
36
36
  this.logger = logger;
37
37
  this.feed = feed;
38
+ // Webhook handlers never release leases — the orchestrator's
39
+ // run finalizer owns that. So when a test passes a bare
40
+ // enqueueIssue callback, wrap it in a dispatcher with a no-op
41
+ // releaseLease (any production caller passes a real dispatcher).
42
+ this.wakeDispatcher = wakeDispatcherOrEnqueueIssue instanceof WakeDispatcher
43
+ ? wakeDispatcherOrEnqueueIssue
44
+ : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
38
45
  this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
39
46
  this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
40
- this.commentWakeHandler = new CommentWakeHandler(db, codex, logger, feed);
41
- this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
42
- this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
47
+ this.commentWakeHandler = new CommentWakeHandler(db, codex, this.wakeDispatcher, logger, feed);
48
+ this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed);
49
+ this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, this.wakeDispatcher, feed);
43
50
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
44
51
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
45
- this.dependencyReadinessHandler = new DependencyReadinessHandler(db, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
52
+ this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
46
53
  }
47
54
  async processWebhookEvent(webhookEventId) {
48
55
  const event = this.db.webhookEvents.getWebhookPayload(webhookEventId);
@@ -145,15 +152,12 @@ export class WebhookHandler {
145
152
  wakeRunType: result.wakeRunType,
146
153
  delegated: result.delegated,
147
154
  peekPendingSessionWakeRunType: (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId),
148
- enqueuePendingSessionWake: (projectId, issueId) => this.enqueuePendingSessionWake(projectId, issueId),
149
155
  isDirectReplyToOutstandingQuestion: (targetIssue) => this.isDirectReplyToOutstandingQuestion(targetIssue),
150
156
  });
151
157
  await this.commentWakeHandler.handle({
152
158
  normalized: hydrated,
153
159
  project,
154
160
  trackedIssue,
155
- enqueuePendingSessionWake: (projectId, issueId) => this.enqueuePendingSessionWake(projectId, issueId),
156
- peekPendingSessionWakeRunType: (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId),
157
161
  isDirectReplyToOutstandingQuestion: (targetIssue) => this.isDirectReplyToOutstandingQuestion(targetIssue),
158
162
  });
159
163
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
@@ -174,8 +178,11 @@ export class WebhookHandler {
174
178
  });
175
179
  }
176
180
  for (const dependentIssueId of newlyReadyDependents) {
181
+ // The dependency-readiness handler already dispatched via the
182
+ // wake dispatcher; here we just emit the operator-feed event so
183
+ // the dispatched run shows up in the timeline.
177
184
  const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
178
- const queuedRunType = this.enqueuePendingSessionWake(project.id, dependentIssueId);
185
+ const queuedRunType = this.peekPendingSessionWakeRunType(project.id, dependentIssueId);
179
186
  this.feed?.publish({
180
187
  level: "info",
181
188
  kind: "stage",
@@ -224,12 +231,7 @@ export class WebhookHandler {
224
231
  return this.db.issueSessions.peekIssueSessionWake(projectId, issueId)?.runType;
225
232
  }
226
233
  enqueuePendingSessionWake(projectId, issueId) {
227
- const wake = this.db.issueSessions.peekIssueSessionWake(projectId, issueId);
228
- if (!wake) {
229
- return undefined;
230
- }
231
- this.enqueueIssue(projectId, issueId);
232
- return wake.runType;
234
+ return this.wakeDispatcher.dispatchIfWakePending(projectId, issueId);
233
235
  }
234
236
  isDirectReplyToOutstandingQuestion(issue) {
235
237
  if (!issue)