patchrelay 0.74.6 → 0.74.8

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.
@@ -23,6 +23,7 @@ import { classifyIssue } from "./issue-class.js";
23
23
  import { buildIssueTriageHash, IssueTriageService } from "./issue-triage.js";
24
24
  import { loadConfig } from "./config.js";
25
25
  import { CodexThreadMaterializingError, isThreadMaterializingError } from "./codex-thread-errors.js";
26
+ import { emitTelemetry, noopTelemetry } from "./telemetry.js";
26
27
  function lowerCaseFirst(value) {
27
28
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
28
29
  }
@@ -84,7 +85,8 @@ export class RunOrchestrator {
84
85
  logger;
85
86
  feed;
86
87
  configPath;
87
- constructor(config, db, codex, linearProvider, enqueueIssue, wakeDispatcherOrLogger, loggerOrFeed, feedOrConfigPath, configPathOrUndefined) {
88
+ telemetry;
89
+ constructor(config, db, codex, linearProvider, enqueueIssue, wakeDispatcherOrLogger, loggerOrFeed, feedOrConfigPath, configPathOrUndefined, telemetryOrUndefined) {
88
90
  this.config = config;
89
91
  this.db = db;
90
92
  this.codex = codex;
@@ -95,6 +97,7 @@ export class RunOrchestrator {
95
97
  let logger;
96
98
  let feed;
97
99
  let configPath;
100
+ const telemetry = telemetryOrUndefined ?? noopTelemetry;
98
101
  if (wakeDispatcherOrLogger instanceof WakeDispatcher) {
99
102
  this.wakeDispatcher = wakeDispatcherOrLogger;
100
103
  logger = loggerOrFeed;
@@ -109,15 +112,16 @@ export class RunOrchestrator {
109
112
  // gets wired below once the lease service exists. The stub is
110
113
  // never called before the wiring completes because the run()
111
114
  // loop is the only consumer of releaseRunAndDispatch.
112
- this.wakeDispatcher = new WakeDispatcher(db, enqueueIssue, (projectId, linearIssueId) => this.leaseService?.release(projectId, linearIssueId), logger, feed);
115
+ this.wakeDispatcher = new WakeDispatcher(db, enqueueIssue, (projectId, linearIssueId) => this.leaseService?.release(projectId, linearIssueId), logger, feed, telemetry);
113
116
  }
114
117
  this.logger = logger;
115
118
  this.feed = feed;
116
119
  this.configPath = configPath;
120
+ this.telemetry = telemetry;
117
121
  this.worktreeManager = new WorktreeManager(config);
118
122
  this.codexRuntimeConfig = config.runner.codex;
119
123
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
120
- this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
124
+ this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry, telemetry);
121
125
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
122
126
  this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
123
127
  this.completionCheck = new CompletionCheckService(codex, logger);
@@ -237,9 +241,20 @@ export class RunOrchestrator {
237
241
  }
238
242
  // ─── Run ────────────────────────────────────────────────────────
239
243
  async run(item) {
244
+ emitTelemetry(this.telemetry, {
245
+ type: "queue.dequeued",
246
+ projectId: item.projectId,
247
+ linearIssueId: item.issueId,
248
+ });
249
+ emitTelemetry(this.telemetry, {
250
+ type: "run.dequeued",
251
+ projectId: item.projectId,
252
+ linearIssueId: item.issueId,
253
+ });
240
254
  await this.refreshCodexRuntimeConfig();
241
255
  const project = this.config.projects.find((p) => p.id === item.projectId);
242
256
  if (!project) {
257
+ this.emitRunSkipped(item, "project_not_configured");
243
258
  this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "project_not_configured" }, "Skipped issue run: project missing from config");
244
259
  return;
245
260
  }
@@ -248,30 +263,38 @@ export class RunOrchestrator {
248
263
  // pending wake didn't actually run. The original incident
249
264
  // (LSR-495) was undiagnosable because these guards were silent.
250
265
  if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
266
+ this.emitRunSkipped(item, "lease_held_locally");
251
267
  this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "lease_held_locally" }, "Skipped issue run: another in-process call still holds the lease");
252
268
  return;
253
269
  }
254
270
  const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
255
271
  if (!initialIssue) {
272
+ this.emitRunSkipped(item, "issue_missing");
256
273
  this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "issue_missing" }, "Skipped issue run: issue row not found");
257
274
  return;
258
275
  }
259
276
  if (initialIssue.activeRunId !== undefined) {
277
+ this.emitActiveRunBlockerInvariant(initialIssue);
278
+ this.emitRunSkipped(item, "active_run_present", initialIssue, { activeRunId: initialIssue.activeRunId });
260
279
  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");
261
280
  return;
262
281
  }
263
282
  const issue = await this.classifyTrackedIssue(initialIssue);
264
283
  if (!issue) {
284
+ this.emitRunSkipped(item, "classification_dropped_issue");
265
285
  this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "classification_dropped_issue" }, "Skipped issue run: classification returned no issue");
266
286
  return;
267
287
  }
268
288
  if (issue.activeRunId !== undefined) {
289
+ this.emitActiveRunBlockerInvariant(issue);
290
+ this.emitRunSkipped(item, "active_run_present_post_classify", issue, { activeRunId: issue.activeRunId });
269
291
  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");
270
292
  return;
271
293
  }
272
294
  const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
273
295
  const leaseId = this.leaseService.acquire(item.projectId, item.issueId);
274
296
  if (!leaseId) {
297
+ this.emitRunSkipped(item, "lease_acquire_failed", issue);
275
298
  this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_acquire_failed" }, "Skipped issue run: another worker holds the session lease");
276
299
  return;
277
300
  }
@@ -283,19 +306,23 @@ export class RunOrchestrator {
283
306
  const wakeIssue = this.materializeLegacyPendingWake(issue, { projectId: item.projectId, linearIssueId: item.issueId, leaseId });
284
307
  const wake = this.resolveRunWake(wakeIssue);
285
308
  if (!wake) {
309
+ this.emitRunSkipped(item, "no_wake_derivable", issue);
286
310
  this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "no_wake_derivable" }, "Skipped issue run: no actionable wake derivable from pending events");
287
311
  this.leaseService.release(item.projectId, item.issueId);
288
312
  return;
289
313
  }
290
314
  const { runType, context, resumeThread } = wake;
291
315
  if (runType === "implementation" && this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId) > 0) {
316
+ const blockerCount = this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId);
292
317
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(item.projectId, item.issueId);
293
318
  this.releaseIssueSessionLease(item.projectId, item.issueId);
319
+ this.emitRunSkipped(item, "blocked", issue, { runType, blockerCount });
294
320
  this.logger.info({ issueKey: issue.issueKey }, "Skipped implementation launch because the issue is blocked");
295
321
  return;
296
322
  }
297
323
  const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
298
324
  if (remainingZombieDelayMs > 0) {
325
+ this.emitRunSkipped(item, "zombie_backoff", issue, { runType, remainingDelayMs: remainingZombieDelayMs });
299
326
  this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
300
327
  this.releaseIssueSessionLease(item.projectId, item.issueId);
301
328
  return;
@@ -314,6 +341,7 @@ export class RunOrchestrator {
314
341
  const dismissed = this.db.issueSessions.dismissIssueSessionEventsWithLease(lease, requestedChangesEventIds);
315
342
  if (!dismissed) {
316
343
  this.releaseIssueSessionLease(item.projectId, item.issueId);
344
+ this.emitRunSkipped(item, "lease_lost_dismissing_inactive_requested_changes_wake", issue, { runType });
317
345
  this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_lost_dismissing_inactive_requested_changes_wake" }, "Skipped issue run: lost lease while dismissing inactive requested-changes wake");
318
346
  return;
319
347
  }
@@ -335,6 +363,7 @@ export class RunOrchestrator {
335
363
  prReviewState: launchIssue.prReviewState,
336
364
  prState: launchIssue.prState,
337
365
  }, "Skipped issue run: requested-changes wake is no longer active");
366
+ this.emitRunSkipped(item, "inactive_requested_changes_wake", issue, { runType });
338
367
  this.releaseIssueSessionLease(item.projectId, item.issueId);
339
368
  this.wakeDispatcher.dispatchIfWakePending(item.projectId, item.issueId);
340
369
  return;
@@ -363,10 +392,12 @@ export class RunOrchestrator {
363
392
  : issue.prHeadSha;
364
393
  const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
365
394
  if (budgetExceeded) {
395
+ this.emitRunSkipped(item, "budget_exceeded", issue, { runType });
366
396
  this.escalate(issue, runType, budgetExceeded);
367
397
  return;
368
398
  }
369
399
  if (!this.runWakePlanner.incrementAttemptCounters(issue, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, runType, isRequestedChangesRunType)) {
400
+ this.emitRunSkipped(item, "lease_lost_incrementing_attempts", issue, { runType });
370
401
  this.releaseIssueSessionLease(item.projectId, item.issueId);
371
402
  return;
372
403
  }
@@ -390,6 +421,7 @@ export class RunOrchestrator {
390
421
  worktreePath,
391
422
  });
392
423
  if (!run) {
424
+ this.emitRunSkipped(item, "claim_failed", issue, { runType });
393
425
  this.releaseIssueSessionLease(item.projectId, item.issueId);
394
426
  return;
395
427
  }
@@ -453,6 +485,37 @@ export class RunOrchestrator {
453
485
  async resetWorktreeToTrackedBranch(worktreePath, branchName, issue) {
454
486
  await this.worktreeManager.resetWorktreeToTrackedBranch(worktreePath, branchName, issue, this.logger);
455
487
  }
488
+ emitRunSkipped(item, reason, issue, details) {
489
+ emitTelemetry(this.telemetry, {
490
+ type: "run.skipped",
491
+ projectId: item.projectId,
492
+ linearIssueId: item.issueId,
493
+ ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
494
+ reason,
495
+ ...(details?.runType ? { runType: details.runType } : {}),
496
+ ...(details?.activeRunId !== undefined ? { activeRunId: details.activeRunId } : {}),
497
+ ...(details?.blockerCount !== undefined ? { blockerCount: details.blockerCount } : {}),
498
+ ...(details?.remainingDelayMs !== undefined ? { remainingDelayMs: details.remainingDelayMs } : {}),
499
+ });
500
+ }
501
+ emitActiveRunBlockerInvariant(issue) {
502
+ if (issue.activeRunId === undefined)
503
+ return;
504
+ const blockerCount = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
505
+ if (blockerCount === 0)
506
+ return;
507
+ emitTelemetry(this.telemetry, {
508
+ type: "health.invariant",
509
+ invariant: "active_run_with_unresolved_blocker",
510
+ status: "observed",
511
+ projectId: issue.projectId,
512
+ linearIssueId: issue.linearIssueId,
513
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
514
+ runId: issue.activeRunId,
515
+ blockerCount,
516
+ detail: "Run dequeue found an active run while blockers are unresolved",
517
+ });
518
+ }
456
519
  async restoreIdleWorktree(issue) {
457
520
  await this.worktreeManager.restoreIdleWorktree(issue, this.logger);
458
521
  }
package/dist/service.js CHANGED
@@ -17,6 +17,7 @@ import { acceptIncomingWebhook } from "./service-webhooks.js";
17
17
  import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
18
18
  import { AgentInputService } from "./agent-input-service.js";
19
19
  import { CodexFollowupIntentClassifier } from "./followup-intent.js";
20
+ import { FanoutPatchRelayTelemetry, LoggerTelemetrySink, OperatorFeedTelemetrySink } from "./telemetry.js";
20
21
  export class PatchRelayService {
21
22
  config;
22
23
  db;
@@ -43,6 +44,11 @@ export class PatchRelayService {
43
44
  this.configPath = configPath;
44
45
  this.linearProvider = toLinearClientProvider(linearProvider);
45
46
  this.feed = new OperatorEventFeed(db.operatorFeed);
47
+ const telemetry = new FanoutPatchRelayTelemetry([
48
+ new LoggerTelemetrySink(logger),
49
+ new OperatorFeedTelemetrySink(this.feed),
50
+ ]);
51
+ db.setTelemetry(telemetry);
46
52
  let enqueueIssue = () => {
47
53
  throw new Error("Service runtime enqueueIssue is not initialized");
48
54
  };
@@ -55,11 +61,11 @@ export class PatchRelayService {
55
61
  // runtime owns the queue, and the lease service lives inside the
56
62
  // orchestrator (its construction depends on the Codex client). All
57
63
  // downstream consumers receive this single dispatcher instance.
58
- const dispatcher = new WakeDispatcher(db, (projectId, issueId) => enqueueIssue(projectId, issueId), (projectId, issueId) => leaseRelease(projectId, issueId), logger, this.feed);
64
+ const dispatcher = new WakeDispatcher(db, (projectId, issueId) => enqueueIssue(projectId, issueId), (projectId, issueId) => leaseRelease(projectId, issueId), logger, this.feed, telemetry);
59
65
  const agentInput = new AgentInputService(db, codex, dispatcher, logger, this.feed, new CodexFollowupIntentClassifier(codex, logger));
60
- this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), dispatcher, logger, this.feed, this.configPath);
66
+ this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), dispatcher, logger, this.feed, this.configPath, telemetry);
61
67
  leaseRelease = (projectId, issueId) => this.orchestrator.leaseService.release(projectId, issueId);
62
- this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput);
68
+ this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput, telemetry);
63
69
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, dispatcher, logger, codex, this.feed);
64
70
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
65
71
  processIssue: async (item) => {
@@ -0,0 +1,85 @@
1
+ export const noopTelemetry = {
2
+ emit: () => undefined,
3
+ };
4
+ export function emitTelemetry(telemetry, event) {
5
+ try {
6
+ telemetry.emit(event);
7
+ }
8
+ catch {
9
+ // Telemetry must never affect workflow execution.
10
+ }
11
+ }
12
+ export class FanoutPatchRelayTelemetry {
13
+ sinks;
14
+ constructor(sinks) {
15
+ this.sinks = sinks;
16
+ }
17
+ emit(event) {
18
+ for (const sink of this.sinks) {
19
+ emitTelemetry(sink, event);
20
+ }
21
+ }
22
+ }
23
+ export class MemoryPatchRelayTelemetry {
24
+ events = [];
25
+ emit(event) {
26
+ this.events.push(event);
27
+ }
28
+ list(type) {
29
+ if (!type)
30
+ return this.events;
31
+ return this.events.filter((event) => event.type === type);
32
+ }
33
+ }
34
+ export class LoggerTelemetrySink {
35
+ logger;
36
+ constructor(logger) {
37
+ this.logger = logger;
38
+ }
39
+ emit(event) {
40
+ this.logger.info({ telemetryEvent: event.type, ...event }, "PatchRelay telemetry event");
41
+ }
42
+ }
43
+ export class OperatorFeedTelemetrySink {
44
+ feed;
45
+ constructor(feed) {
46
+ this.feed = feed;
47
+ }
48
+ emit(event) {
49
+ const feedEvent = this.toFeedEvent(event);
50
+ if (feedEvent) {
51
+ this.feed.publish(feedEvent);
52
+ }
53
+ }
54
+ toFeedEvent(event) {
55
+ switch (event.type) {
56
+ case "dependency.dependent_unblocked":
57
+ return {
58
+ level: "info",
59
+ kind: "workflow",
60
+ ...(event.issueKey ? { issueKey: event.issueKey } : {}),
61
+ ...(event.projectId ? { projectId: event.projectId } : {}),
62
+ ...(event.dispatchedRunType ? { stage: event.dispatchedRunType } : {}),
63
+ status: "dependency_unblocked",
64
+ summary: event.dispatchedRunType
65
+ ? `Dependency unblocked; ${event.dispatchedRunType} queued`
66
+ : "Dependency unblocked",
67
+ };
68
+ case "run.skipped":
69
+ if (event.reason === "blocked" || event.reason === "lease_acquire_failed" || event.reason === "no_wake_derivable") {
70
+ return {
71
+ level: event.reason === "blocked" ? "info" : "warn",
72
+ kind: "stage",
73
+ ...(event.issueKey ? { issueKey: event.issueKey } : {}),
74
+ ...(event.projectId ? { projectId: event.projectId } : {}),
75
+ ...(event.runType ? { stage: event.runType } : {}),
76
+ status: "skipped",
77
+ summary: `Run skipped: ${event.reason}`,
78
+ };
79
+ }
80
+ return undefined;
81
+ default:
82
+ return undefined;
83
+ }
84
+ }
85
+ }
@@ -1,3 +1,4 @@
1
+ import { emitTelemetry, noopTelemetry } from "./telemetry.js";
1
2
  // Single owner of "append a session event and tell the orchestrator
2
3
  // something might be runnable", and of "release a finished run so the
3
4
  // next wake fires." Until this existed, 8+ call sites each made their
@@ -21,13 +22,15 @@ export class WakeDispatcher {
21
22
  releaseLease;
22
23
  logger;
23
24
  feed;
25
+ telemetry;
24
26
  currentTick;
25
- constructor(db, enqueueIssue, releaseLease, logger, feed) {
27
+ constructor(db, enqueueIssue, releaseLease, logger, feed, telemetry = noopTelemetry) {
26
28
  this.db = db;
27
29
  this.enqueueIssue = enqueueIssue;
28
30
  this.releaseLease = releaseLease;
29
31
  this.logger = logger;
30
32
  this.feed = feed;
33
+ this.telemetry = telemetry;
31
34
  }
32
35
  // Scope the next enqueue calls inside `fn` to a single dedupe Set.
33
36
  // Nested ticks reuse the outermost Set so deeply nested helpers do
@@ -48,11 +51,23 @@ export class WakeDispatcher {
48
51
  // would have, or undefined if the event is non-actionable / no wake
49
52
  // exists / a run is already running (the finalizer will drain it).
50
53
  recordEventAndDispatch(projectId, linearIssueId, event, options) {
51
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
54
+ const appended = this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
52
55
  projectId,
53
56
  linearIssueId,
54
57
  ...event,
55
58
  });
59
+ const issue = this.db.issues.getIssue(projectId, linearIssueId);
60
+ if (appended) {
61
+ emitTelemetry(this.telemetry, {
62
+ type: "wake.created",
63
+ projectId,
64
+ linearIssueId,
65
+ ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
66
+ eventIds: [appended.id],
67
+ sessionEventType: event.eventType,
68
+ ...(event.dedupeKey ? { dedupeKey: event.dedupeKey } : {}),
69
+ });
70
+ }
56
71
  // Honour the active tick scope (set via withTick) so callers nested
57
72
  // inside a reconcile pass automatically dedupe without threading
58
73
  // the Set through every helper signature.
@@ -67,22 +82,143 @@ export class WakeDispatcher {
67
82
  // post-run drain via releaseRunAndDispatch.
68
83
  dispatchIfWakePending(projectId, linearIssueId, options) {
69
84
  const issue = this.db.issues.getIssue(projectId, linearIssueId);
70
- if (issue?.activeRunId !== undefined)
85
+ if (!issue) {
86
+ emitTelemetry(this.telemetry, {
87
+ type: "wake.suppressed",
88
+ projectId,
89
+ linearIssueId,
90
+ reason: "issue_missing",
91
+ });
92
+ return undefined;
93
+ }
94
+ if (issue.activeRunId !== undefined) {
95
+ const blockerCount = this.db.issues.countUnresolvedBlockers(projectId, linearIssueId);
96
+ emitTelemetry(this.telemetry, {
97
+ type: "wake.suppressed",
98
+ projectId,
99
+ linearIssueId,
100
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
101
+ reason: "active_run_present",
102
+ activeRunId: issue.activeRunId,
103
+ ...(blockerCount > 0 ? { blockerCount } : {}),
104
+ });
105
+ if (blockerCount > 0) {
106
+ emitTelemetry(this.telemetry, {
107
+ type: "health.invariant",
108
+ invariant: "active_run_with_unresolved_blocker",
109
+ status: "observed",
110
+ projectId,
111
+ linearIssueId,
112
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
113
+ runId: issue.activeRunId,
114
+ blockerCount,
115
+ detail: "Wake suppressed because an active run exists while blockers are unresolved",
116
+ });
117
+ }
118
+ return undefined;
119
+ }
120
+ const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(projectId, linearIssueId);
121
+ if (unresolvedBlockers > 0) {
122
+ const blockerKeys = this.unresolvedBlockerKeys(projectId, linearIssueId);
123
+ emitTelemetry(this.telemetry, {
124
+ type: "wake.suppressed",
125
+ projectId,
126
+ linearIssueId,
127
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
128
+ reason: "blocked",
129
+ blockerCount: unresolvedBlockers,
130
+ blockerKeys,
131
+ });
132
+ const pendingBlockedWake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId) ?? issue.pendingRunType;
133
+ if (pendingBlockedWake) {
134
+ emitTelemetry(this.telemetry, {
135
+ type: "health.invariant",
136
+ invariant: "blocked_issue_with_pending_wake",
137
+ status: "observed",
138
+ projectId,
139
+ linearIssueId,
140
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
141
+ blockerCount: unresolvedBlockers,
142
+ detail: "Wake remains pending while blockers are unresolved",
143
+ });
144
+ }
71
145
  return undefined;
146
+ }
72
147
  const wake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
73
148
  // Fall back to the legacy pending_run_type column. The orchestrator
74
149
  // materializes it into a real event at run time, but the poke still
75
150
  // needs to happen now so the orchestrator gets called at all.
76
- const runType = wake?.runType ?? issue?.pendingRunType;
77
- if (!runType)
151
+ const runType = wake?.runType ?? issue.pendingRunType;
152
+ if (!runType) {
153
+ emitTelemetry(this.telemetry, {
154
+ type: "wake.suppressed",
155
+ projectId,
156
+ linearIssueId,
157
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
158
+ reason: "no_wake_derivable",
159
+ });
160
+ if (this.db.listIssuesReadyForExecution().some((entry) => entry.projectId === projectId && entry.linearIssueId === linearIssueId)) {
161
+ emitTelemetry(this.telemetry, {
162
+ type: "health.invariant",
163
+ invariant: "ready_issue_not_enqueued",
164
+ status: "observed",
165
+ projectId,
166
+ linearIssueId,
167
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
168
+ detail: "Issue appears ready for execution but no wake was derivable for enqueue",
169
+ });
170
+ }
78
171
  return undefined;
172
+ }
173
+ emitTelemetry(this.telemetry, {
174
+ type: "wake.derived",
175
+ projectId,
176
+ linearIssueId,
177
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
178
+ runType,
179
+ ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
180
+ ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
181
+ source: wake ? (wake.eventIds.length > 0 ? "session_event" : "implicit") : "legacy_pending_run_type",
182
+ });
79
183
  const tick = options?.enqueuedThisTick ?? this.currentTick;
80
184
  const key = `${projectId}:${linearIssueId}`;
81
185
  if (tick?.has(key)) {
186
+ emitTelemetry(this.telemetry, {
187
+ type: "wake.deduped",
188
+ projectId,
189
+ linearIssueId,
190
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
191
+ runType,
192
+ ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
193
+ ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
194
+ });
195
+ emitTelemetry(this.telemetry, {
196
+ type: "queue.deduped",
197
+ projectId,
198
+ linearIssueId,
199
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
200
+ runType,
201
+ });
82
202
  return runType;
83
203
  }
84
204
  tick?.add(key);
85
205
  this.enqueueIssue(projectId, linearIssueId);
206
+ emitTelemetry(this.telemetry, {
207
+ type: "wake.dispatched",
208
+ projectId,
209
+ linearIssueId,
210
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
211
+ runType,
212
+ ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
213
+ ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
214
+ });
215
+ emitTelemetry(this.telemetry, {
216
+ type: "queue.enqueued",
217
+ projectId,
218
+ linearIssueId,
219
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
220
+ runType,
221
+ });
86
222
  return runType;
87
223
  }
88
224
  // Release the lease for a finished run, then drain any wake that
@@ -97,10 +233,42 @@ export class WakeDispatcher {
97
233
  // check paths publish their own more-specific event and pass false.
98
234
  releaseRunAndDispatch(params) {
99
235
  this.releaseLease(params.run.projectId, params.run.linearIssueId);
236
+ emitTelemetry(this.telemetry, {
237
+ type: "run.released",
238
+ projectId: params.run.projectId,
239
+ linearIssueId: params.run.linearIssueId,
240
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
241
+ runId: params.run.id,
242
+ runType: params.run.runType,
243
+ });
100
244
  const wake = this.db.workflowWakes.peekIssueWake(params.run.projectId, params.run.linearIssueId);
101
- if (!wake)
245
+ if (!wake) {
246
+ emitTelemetry(this.telemetry, {
247
+ type: "wake.suppressed",
248
+ projectId: params.run.projectId,
249
+ linearIssueId: params.run.linearIssueId,
250
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
251
+ reason: "no_wake_derivable",
252
+ });
102
253
  return undefined;
254
+ }
103
255
  this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
256
+ emitTelemetry(this.telemetry, {
257
+ type: "wake.dispatched",
258
+ projectId: params.run.projectId,
259
+ linearIssueId: params.run.linearIssueId,
260
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
261
+ runType: wake.runType,
262
+ ...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
263
+ eventIds: wake.eventIds,
264
+ });
265
+ emitTelemetry(this.telemetry, {
266
+ type: "queue.enqueued",
267
+ projectId: params.run.projectId,
268
+ linearIssueId: params.run.linearIssueId,
269
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
270
+ runType: wake.runType,
271
+ });
104
272
  if (params.publishDeferredFollowUp) {
105
273
  this.feed?.publish({
106
274
  level: "info",
@@ -118,4 +286,10 @@ export class WakeDispatcher {
118
286
  ...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
119
287
  };
120
288
  }
289
+ unresolvedBlockerKeys(projectId, linearIssueId) {
290
+ return this.db.issues.listIssueDependencies(projectId, linearIssueId)
291
+ .filter((entry) => entry.blockerCurrentLinearStateType !== "completed"
292
+ && entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done")
293
+ .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
294
+ }
121
295
  }
@@ -14,6 +14,7 @@ import { extractLatestAssistantSummary } from "./issue-session-events.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
15
  import { CodexFollowupIntentClassifier } from "./followup-intent.js";
16
16
  import { AgentInputService } from "./agent-input-service.js";
17
+ import { noopTelemetry } from "./telemetry.js";
17
18
  export class WebhookHandler {
18
19
  config;
19
20
  db;
@@ -21,6 +22,7 @@ export class WebhookHandler {
21
22
  codex;
22
23
  logger;
23
24
  feed;
25
+ telemetry;
24
26
  installationHandler;
25
27
  issueRemovalHandler;
26
28
  commentWakeHandler;
@@ -30,20 +32,21 @@ export class WebhookHandler {
30
32
  dependencyReadinessHandler;
31
33
  linearSync;
32
34
  wakeDispatcher;
33
- constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier, agentInput) {
35
+ constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier, agentInput, telemetry = noopTelemetry) {
34
36
  this.config = config;
35
37
  this.db = db;
36
38
  this.linearProvider = linearProvider;
37
39
  this.codex = codex;
38
40
  this.logger = logger;
39
41
  this.feed = feed;
42
+ this.telemetry = telemetry;
40
43
  // Webhook handlers never release leases — the orchestrator's
41
44
  // run finalizer owns that. So when a test passes a bare
42
45
  // enqueueIssue callback, wrap it in a dispatcher with a no-op
43
46
  // releaseLease (any production caller passes a real dispatcher).
44
47
  this.wakeDispatcher = wakeDispatcherOrEnqueueIssue instanceof WakeDispatcher
45
48
  ? wakeDispatcherOrEnqueueIssue
46
- : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
49
+ : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed, telemetry);
47
50
  this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
48
51
  this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
49
52
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
@@ -53,7 +56,7 @@ export class WebhookHandler {
53
56
  this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed, agentInputService);
54
57
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, this.wakeDispatcher, feed);
55
58
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
56
- this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
59
+ this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId), telemetry);
57
60
  }
58
61
  async processWebhookEvent(webhookEventId) {
59
62
  const event = this.db.webhookEvents.getWebhookPayload(webhookEventId);