patchrelay 0.74.5 → 0.74.7
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.
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/repo.js +2 -2
- package/dist/db/issue-session-store.js +46 -4
- package/dist/db/issue-store.js +9 -4
- package/dist/db/run-store.js +44 -8
- package/dist/db.js +44 -11
- package/dist/issue-session-lease-service.js +57 -3
- package/dist/issue-session-projection-invalidator.js +90 -0
- package/dist/run-orchestrator.js +66 -3
- package/dist/service.js +9 -3
- package/dist/telemetry.js +85 -0
- package/dist/wake-dispatcher.js +180 -6
- package/dist/webhook-handler.js +6 -3
- package/dist/webhooks/dependency-readiness-handler.js +57 -6
- package/dist/workflow-wake-resolver.js +5 -3
- package/package.json +1 -1
package/dist/run-orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/wake-dispatcher.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/dist/webhook-handler.js
CHANGED
|
@@ -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);
|