patchrelay 0.36.11 → 0.36.13

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.
@@ -1,17 +1,17 @@
1
- import { TERMINAL_STATES } from "./factory-state.js";
2
- import { extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread } from "./run-reporting.js";
3
- import { buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
1
+ import { summarizeCurrentThread } from "./run-reporting.js";
2
+ import { buildRunStartedActivity, } from "./linear-session-reporting.js";
4
3
  import { WorktreeManager } from "./worktree-manager.js";
5
- import { resolveAuthoritativeLinearStopState, resolvePreferredCompletedLinearState } from "./linear-workflow.js";
6
- import { getThreadTurns } from "./codex-thread-utils.js";
4
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
7
5
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
8
6
  import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
9
7
  import { LinearSessionSync } from "./linear-session-sync.js";
10
8
  import { IssueSessionLeaseService } from "./issue-session-lease-service.js";
11
- import { InterruptedRunRecovery, resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
9
+ import { InterruptedRunRecovery } from "./interrupted-run-recovery.js";
12
10
  import { RunCompletionPolicy } from "./run-completion-policy.js";
13
11
  import { RunFinalizer } from "./run-finalizer.js";
14
12
  import { RunLauncher } from "./run-launcher.js";
13
+ import { RunNotificationHandler } from "./run-notification-handler.js";
14
+ import { RunReconciler } from "./run-reconciler.js";
15
15
  import { RunRecoveryService } from "./run-recovery-service.js";
16
16
  import { RunWakePlanner } from "./run-wake-planner.js";
17
17
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
@@ -41,7 +41,6 @@ export class RunOrchestrator {
41
41
  queueHealthMonitor;
42
42
  idleReconciler;
43
43
  linearSync;
44
- activeThreadId;
45
44
  workerId = `patchrelay:${process.pid}`;
46
45
  leaseService;
47
46
  runFinalizer;
@@ -50,6 +49,8 @@ export class RunOrchestrator {
50
49
  runWakePlanner;
51
50
  interruptedRunRecovery;
52
51
  runCompletionPolicy;
52
+ runNotificationHandler;
53
+ runReconciler;
53
54
  activeSessionLeases;
54
55
  botIdentity;
55
56
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -67,8 +68,10 @@ export class RunOrchestrator {
67
68
  this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn));
68
69
  this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, feed);
69
70
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
71
+ this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), feed);
70
72
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), (newState, pendingRunType) => this.resolveBranchOwnerForStateTransition(newState, pendingRunType), feed);
71
73
  this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (run, message, nextState) => this.failRunAndClear(run, message, nextState), (issue) => this.restoreIdleWorktree(issue), this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
74
+ this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason), feed);
72
75
  this.runWakePlanner = new RunWakePlanner(db);
73
76
  this.idleReconciler = new IdleIssueReconciler(db, config, {
74
77
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
@@ -219,101 +222,7 @@ export class RunOrchestrator {
219
222
  }
220
223
  // ─── Notification handler ─────────────────────────────────────────
221
224
  async handleCodexNotification(notification) {
222
- // threadId is present on turn-level notifications but NOT on item-level ones.
223
- // Fall back to the tracked active thread for item/delta notifications.
224
- let threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
225
- if (!threadId) {
226
- threadId = this.activeThreadId;
227
- }
228
- if (!threadId)
229
- return;
230
- // Track the active thread from turn/started so item notifications can find it
231
- if (notification.method === "turn/started" && threadId) {
232
- this.activeThreadId = threadId;
233
- }
234
- const run = this.db.runs.getRunByThreadId(threadId);
235
- if (!run)
236
- return;
237
- if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
238
- this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Ignoring Codex notification after losing issue-session lease");
239
- return;
240
- }
241
- const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
242
- if (this.config.runner.codex.persistExtendedHistory) {
243
- this.db.runs.saveThreadEvent({
244
- runId: run.id,
245
- threadId,
246
- ...(turnId ? { turnId } : {}),
247
- method: notification.method,
248
- eventJson: JSON.stringify(notification.params),
249
- });
250
- }
251
- // Emit ephemeral progress activity to Linear for notable in-flight events
252
- this.linearSync.maybeEmitProgress(notification, run);
253
- // Sync codex plan to Linear session when it updates
254
- if (notification.method === "turn/plan/updated") {
255
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
256
- if (issue) {
257
- void this.linearSync.syncCodexPlan(issue, notification.params);
258
- }
259
- }
260
- if (notification.method !== "turn/completed")
261
- return;
262
- const thread = await this.readThreadWithRetry(threadId);
263
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
264
- if (!issue)
265
- return;
266
- const completedTurnId = extractTurnId(notification.params);
267
- const status = resolveRunCompletionStatus(notification.params);
268
- if (status === "failed") {
269
- const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
270
- const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
271
- this.db.issueSessions.finishRunWithLease(lease, run.id, {
272
- status: "failed",
273
- threadId,
274
- ...(completedTurnId ? { turnId: completedTurnId } : {}),
275
- failureReason: "Codex reported the turn completed in a failed state",
276
- });
277
- this.db.issueSessions.upsertIssueWithLease(lease, {
278
- projectId: run.projectId,
279
- linearIssueId: run.linearIssueId,
280
- activeRunId: null,
281
- factoryState: nextState,
282
- });
283
- return true;
284
- });
285
- if (!updated) {
286
- this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failed-turn cleanup after losing issue-session lease");
287
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
288
- return;
289
- }
290
- this.feed?.publish({
291
- level: "error",
292
- kind: "turn",
293
- issueKey: issue.issueKey,
294
- projectId: run.projectId,
295
- stage: run.runType,
296
- status: "failed",
297
- summary: `Turn failed for ${run.runType}`,
298
- });
299
- const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
300
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType));
301
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
302
- this.linearSync.clearProgress(run.id);
303
- this.activeThreadId = undefined;
304
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
305
- return;
306
- }
307
- await this.runFinalizer.finalizeCompletedRun({
308
- source: "notification",
309
- run,
310
- issue,
311
- thread,
312
- threadId,
313
- ...(completedTurnId ? { completedTurnId } : {}),
314
- resolveRecoverableRunState: resolveRecoverablePostRunState,
315
- });
316
- this.activeThreadId = undefined;
225
+ await this.runNotificationHandler.handle(notification);
317
226
  }
318
227
  // ─── Active status for query ──────────────────────────────────────
319
228
  async getActiveRunStatus(issueKey) {
@@ -408,114 +317,7 @@ export class RunOrchestrator {
408
317
  }
409
318
  if (recoveryLease === "skip")
410
319
  return;
411
- const acquiredRecoveryLease = recoveryLease === true;
412
- // If the issue reached a terminal state while this run was active
413
- // (e.g. pr_merged processed, DB manually edited), just release the run.
414
- if (TERMINAL_STATES.has(issue.factoryState)) {
415
- this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
416
- this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
417
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
418
- });
419
- this.logger.info({ issueKey: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
420
- const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
421
- void this.linearSync.syncSession(releasedIssue, { activeRunType: run.runType });
422
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
423
- return;
424
- }
425
- // Zombie run: claimed in DB but Codex never started (no thread).
426
- // If this process still owns the live lease, launch may still be in flight
427
- // between worktree prep and Codex thread creation, so do not self-recover it.
428
- if (!run.threadId) {
429
- if (recoveryLease === "owned") {
430
- this.logger.debug({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
431
- return;
432
- }
433
- this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
434
- this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
435
- this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
436
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
437
- });
438
- this.recoverOrEscalate(issue, run.runType, "zombie");
439
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
440
- void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
441
- void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
442
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
443
- return;
444
- }
445
- // Read Codex state — thread may not exist after app-server restart.
446
- let thread;
447
- try {
448
- thread = await this.readThreadWithRetry(run.threadId);
449
- }
450
- catch {
451
- this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
452
- this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
453
- this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
454
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
455
- });
456
- this.recoverOrEscalate(issue, run.runType, "stale_thread");
457
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
458
- void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
459
- void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
460
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
461
- return;
462
- }
463
- // Check Linear state (non-fatal — token refresh may fail)
464
- const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
465
- if (linear) {
466
- const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
467
- if (linearIssue) {
468
- const stopState = resolveAuthoritativeLinearStopState(linearIssue);
469
- if (stopState?.isFinal) {
470
- this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
471
- this.db.runs.finishRun(run.id, { status: "released" });
472
- this.db.issues.upsertIssue({
473
- projectId: run.projectId,
474
- linearIssueId: run.linearIssueId,
475
- activeRunId: null,
476
- currentLinearState: stopState.stateName,
477
- factoryState: "done",
478
- });
479
- });
480
- this.feed?.publish({
481
- level: "info",
482
- kind: "stage",
483
- issueKey: issue.issueKey,
484
- projectId: run.projectId,
485
- stage: "done",
486
- status: "reconciled",
487
- summary: `Linear state ${stopState.stateName} \u2192 done`,
488
- });
489
- const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
490
- void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
491
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
492
- return;
493
- }
494
- }
495
- }
496
- const latestTurn = getThreadTurns(thread).at(-1);
497
- // Handle interrupted turn — fail the run rather than retrying indefinitely.
498
- // The agent may have partially completed work (commits, PR) before interruption.
499
- // Reactive loops (CI repair, review fix) will handle follow-up if needed.
500
- if (latestTurn?.status === "interrupted") {
501
- await this.interruptedRunRecovery.handle(run, issue);
502
- return;
503
- }
504
- // Handle completed turn discovered during reconciliation
505
- if (latestTurn?.status === "completed") {
506
- await this.runFinalizer.finalizeCompletedRun({
507
- source: "reconciliation",
508
- run,
509
- issue,
510
- thread,
511
- threadId: run.threadId,
512
- ...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
513
- resolveRecoverableRunState: resolveRecoverablePostRunState,
514
- });
515
- return;
516
- }
517
- if (acquiredRecoveryLease)
518
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
320
+ await this.runReconciler.reconcile({ run, issue, recoveryLease });
519
321
  }
520
322
  // ─── Internal helpers ─────────────────────────────────────────────
521
323
  escalate(issue, runType, reason) {
@@ -0,0 +1,132 @@
1
+ import { TERMINAL_STATES } from "./factory-state.js";
2
+ import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
3
+ import { buildRunFailureActivity } from "./linear-session-reporting.js";
4
+ import { getThreadTurns } from "./codex-thread-utils.js";
5
+ import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
6
+ export class RunReconciler {
7
+ db;
8
+ logger;
9
+ linearProvider;
10
+ linearSync;
11
+ interruptedRunRecovery;
12
+ runFinalizer;
13
+ withHeldLease;
14
+ releaseLease;
15
+ readThreadWithRetry;
16
+ recoverOrEscalate;
17
+ feed;
18
+ constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, feed) {
19
+ this.db = db;
20
+ this.logger = logger;
21
+ this.linearProvider = linearProvider;
22
+ this.linearSync = linearSync;
23
+ this.interruptedRunRecovery = interruptedRunRecovery;
24
+ this.runFinalizer = runFinalizer;
25
+ this.withHeldLease = withHeldLease;
26
+ this.releaseLease = releaseLease;
27
+ this.readThreadWithRetry = readThreadWithRetry;
28
+ this.recoverOrEscalate = recoverOrEscalate;
29
+ this.feed = feed;
30
+ }
31
+ async reconcile(params) {
32
+ const { run, issue, recoveryLease } = params;
33
+ const acquiredRecoveryLease = recoveryLease === true;
34
+ if (TERMINAL_STATES.has(issue.factoryState)) {
35
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
36
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
37
+ this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
38
+ });
39
+ this.logger.info({ issueKey: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
40
+ const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
41
+ void this.linearSync.syncSession(releasedIssue, { activeRunType: run.runType });
42
+ this.releaseLease(run.projectId, run.linearIssueId);
43
+ return;
44
+ }
45
+ if (!run.threadId) {
46
+ if (recoveryLease === "owned") {
47
+ this.logger.debug({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
48
+ return;
49
+ }
50
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
51
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
52
+ this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
53
+ this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
54
+ });
55
+ this.recoverOrEscalate(issue, run.runType, "zombie");
56
+ const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
57
+ void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
58
+ void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
59
+ this.releaseLease(run.projectId, run.linearIssueId);
60
+ return;
61
+ }
62
+ let thread;
63
+ try {
64
+ thread = await this.readThreadWithRetry(run.threadId);
65
+ }
66
+ catch {
67
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
68
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
69
+ this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
70
+ this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
71
+ });
72
+ this.recoverOrEscalate(issue, run.runType, "stale_thread");
73
+ const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
74
+ void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
75
+ void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
76
+ this.releaseLease(run.projectId, run.linearIssueId);
77
+ return;
78
+ }
79
+ const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
80
+ if (linear) {
81
+ const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
82
+ if (linearIssue) {
83
+ const stopState = resolveAuthoritativeLinearStopState(linearIssue);
84
+ if (stopState?.isFinal) {
85
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
86
+ this.db.runs.finishRun(run.id, { status: "released" });
87
+ this.db.issues.upsertIssue({
88
+ projectId: run.projectId,
89
+ linearIssueId: run.linearIssueId,
90
+ activeRunId: null,
91
+ currentLinearState: stopState.stateName,
92
+ factoryState: "done",
93
+ });
94
+ });
95
+ this.feed?.publish({
96
+ level: "info",
97
+ kind: "stage",
98
+ issueKey: issue.issueKey,
99
+ projectId: run.projectId,
100
+ stage: "done",
101
+ status: "reconciled",
102
+ summary: `Linear state ${stopState.stateName} -> done`,
103
+ });
104
+ const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
105
+ void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
106
+ this.releaseLease(run.projectId, run.linearIssueId);
107
+ return;
108
+ }
109
+ }
110
+ }
111
+ const latestTurn = getThreadTurns(thread).at(-1);
112
+ if (latestTurn?.status === "interrupted") {
113
+ await this.interruptedRunRecovery.handle(run, issue);
114
+ return;
115
+ }
116
+ if (latestTurn?.status === "completed") {
117
+ await this.runFinalizer.finalizeCompletedRun({
118
+ source: "reconciliation",
119
+ run,
120
+ issue,
121
+ thread,
122
+ threadId: run.threadId,
123
+ ...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
124
+ resolveRecoverableRunState: resolveRecoverablePostRunState,
125
+ });
126
+ return;
127
+ }
128
+ if (acquiredRecoveryLease) {
129
+ this.releaseLease(run.projectId, run.linearIssueId);
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,67 @@
1
+ import { buildTrackedIssueRecord } from "./tracked-issue-projector.js";
2
+ import { deriveIssueSessionState, isIssueSessionReadyForExecution } from "./issue-session.js";
3
+ export class TrackedIssueQuery {
4
+ issues;
5
+ issueSessions;
6
+ runs;
7
+ constructor(issues, issueSessions, runs) {
8
+ this.issues = issues;
9
+ this.issueSessions = issueSessions;
10
+ this.runs = runs;
11
+ }
12
+ listIssuesReadyForExecution() {
13
+ return this.issues.listIssues()
14
+ .filter((issue) => isIssueSessionReadyForExecution({
15
+ factoryState: issue.factoryState,
16
+ sessionState: deriveIssueSessionState({
17
+ activeRunId: issue.activeRunId,
18
+ factoryState: issue.factoryState,
19
+ }),
20
+ activeRunId: issue.activeRunId,
21
+ blockedByCount: this.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId),
22
+ hasPendingWake: this.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined,
23
+ hasLegacyPendingRun: issue.pendingRunType !== undefined,
24
+ prNumber: issue.prNumber,
25
+ prState: issue.prState,
26
+ prReviewState: issue.prReviewState,
27
+ prCheckStatus: issue.prCheckStatus,
28
+ latestFailureSource: issue.lastGitHubFailureSource,
29
+ }))
30
+ .map((issue) => ({
31
+ projectId: issue.projectId,
32
+ linearIssueId: issue.linearIssueId,
33
+ }));
34
+ }
35
+ issueToTrackedIssue(issue) {
36
+ return buildTrackedIssueRecord({
37
+ issue,
38
+ session: this.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId),
39
+ blockedBy: this.issues.listIssueDependencies(issue.projectId, issue.linearIssueId),
40
+ hasPendingWake: this.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined,
41
+ latestRun: this.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId),
42
+ latestEvent: this.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { limit: 1 }).at(-1),
43
+ });
44
+ }
45
+ getTrackedIssue(projectId, linearIssueId) {
46
+ const issue = this.issues.getIssue(projectId, linearIssueId);
47
+ return issue ? this.issueToTrackedIssue(issue) : undefined;
48
+ }
49
+ getTrackedIssueByKey(issueKey) {
50
+ const issue = this.issues.getIssueByKey(issueKey);
51
+ return issue ? this.issueToTrackedIssue(issue) : undefined;
52
+ }
53
+ getIssueOverview(issueKey) {
54
+ const issue = this.issues.getIssueByKey(issueKey);
55
+ if (!issue)
56
+ return undefined;
57
+ const tracked = this.issueToTrackedIssue(issue);
58
+ const activeRun = issue.activeRunId ? this.runs.getRunById(issue.activeRunId) : undefined;
59
+ return {
60
+ issue: tracked,
61
+ ...(activeRun ? { activeRun } : {}),
62
+ };
63
+ }
64
+ listIssuesByState(projectId, state) {
65
+ return this.issues.listIssuesByState(projectId, state);
66
+ }
67
+ }
@@ -1,11 +1,12 @@
1
1
  import { deriveIssueStatusNote } from "./status-note.js";
2
- import { resolveProject, trustedActorAllowed } from "./project-resolution.js";
2
+ import { trustedActorAllowed } from "./project-resolution.js";
3
3
  import { normalizeWebhook } from "./webhooks.js";
4
4
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
5
5
  import { AgentSessionHandler } from "./webhooks/agent-session-handler.js";
6
6
  import { CommentWakeHandler } from "./webhooks/comment-wake-handler.js";
7
+ import { WebhookContextLoader } from "./webhooks/context-loader.js";
8
+ import { DependencyReadinessHandler } from "./webhooks/dependency-readiness-handler.js";
7
9
  import { DesiredStageRecorder } from "./webhooks/desired-stage-recorder.js";
8
- import { hasCompleteIssueContext, mergeIssueMetadata, } from "./webhooks/decision-helpers.js";
9
10
  import { IssueRemovalHandler } from "./webhooks/issue-removal-handler.js";
10
11
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
11
12
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
@@ -22,6 +23,8 @@ export class WebhookHandler {
22
23
  commentWakeHandler;
23
24
  agentSessionHandler;
24
25
  desiredStageRecorder;
26
+ contextLoader;
27
+ dependencyReadinessHandler;
25
28
  constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
26
29
  this.config = config;
27
30
  this.db = db;
@@ -35,6 +38,8 @@ export class WebhookHandler {
35
38
  this.commentWakeHandler = new CommentWakeHandler(db, codex, logger, feed);
36
39
  this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
37
40
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
41
+ this.contextLoader = new WebhookContextLoader(config, linearProvider);
42
+ this.dependencyReadinessHandler = new DependencyReadinessHandler(db, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
38
43
  }
39
44
  async processWebhookEvent(webhookEventId) {
40
45
  const event = this.db.webhookEvents.getWebhookPayload(webhookEventId);
@@ -66,14 +71,8 @@ export class WebhookHandler {
66
71
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
67
72
  return;
68
73
  }
69
- let project = resolveProject(this.config, normalized.issue);
70
- if (!project) {
71
- const routed = await this.tryHydrateProjectRoute(normalized);
72
- if (routed) {
73
- normalized = routed.normalized;
74
- project = routed.project;
75
- }
76
- }
74
+ const routed = await this.contextLoader.load(normalized);
75
+ const project = routed?.project;
77
76
  if (!project) {
78
77
  this.feed?.publish({
79
78
  level: "warn",
@@ -85,6 +84,7 @@ export class WebhookHandler {
85
84
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
86
85
  return;
87
86
  }
87
+ normalized = routed.normalized;
88
88
  const routedIssue = normalized.issue;
89
89
  if (!routedIssue) {
90
90
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
@@ -103,7 +103,7 @@ export class WebhookHandler {
103
103
  return;
104
104
  }
105
105
  this.db.webhookEvents.assignWebhookProject(webhookEventId, project.id);
106
- const hydrated = await this.hydrateIssueContext(project.id, normalized);
106
+ const hydrated = normalized;
107
107
  const issue = hydrated.issue ?? routedIssue;
108
108
  // Record desired stage and upsert issue
109
109
  const result = await this.desiredStageRecorder.record({
@@ -113,7 +113,7 @@ export class WebhookHandler {
113
113
  stopActiveRun: (run, input) => this.stopActiveRun(run, input),
114
114
  });
115
115
  const trackedIssue = result.issue;
116
- const newlyReadyDependents = this.reconcileDependentReadiness(project.id, issue.id);
116
+ const newlyReadyDependents = this.dependencyReadinessHandler.reconcile(project.id, issue.id);
117
117
  // Handle issue removal: release active runs, mark as failed.
118
118
  if (hydrated.triggerEvent === "issueRemoved") {
119
119
  await this.issueRemovalHandler.handle({
@@ -188,48 +188,6 @@ export class WebhookHandler {
188
188
  throw err;
189
189
  }
190
190
  }
191
- reconcileDependentReadiness(projectId, blockerLinearIssueId) {
192
- const newlyReady = [];
193
- for (const dependent of this.db.issues.listDependents(projectId, blockerLinearIssueId)) {
194
- const issue = this.db.issues.getIssue(projectId, dependent.linearIssueId);
195
- if (!issue) {
196
- continue;
197
- }
198
- const unresolved = this.db.issues.countUnresolvedBlockers(projectId, dependent.linearIssueId);
199
- if (unresolved > 0) {
200
- if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
201
- && issue.activeRunId === undefined
202
- && !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
203
- this.db.issues.upsertIssue({
204
- projectId,
205
- linearIssueId: dependent.linearIssueId,
206
- pendingRunType: null,
207
- pendingRunContextJson: null,
208
- });
209
- }
210
- continue;
211
- }
212
- if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
213
- continue;
214
- }
215
- if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
216
- this.db.issues.upsertIssue({
217
- projectId,
218
- linearIssueId: dependent.linearIssueId,
219
- pendingRunType: null,
220
- pendingRunContextJson: null,
221
- });
222
- }
223
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
224
- projectId,
225
- linearIssueId: dependent.linearIssueId,
226
- eventType: "delegated",
227
- dedupeKey: `delegated:${dependent.linearIssueId}`,
228
- });
229
- newlyReady.push(dependent.linearIssueId);
230
- }
231
- return newlyReady;
232
- }
233
191
  async stopActiveRun(run, input) {
234
192
  if (!run.threadId || !run.turnId)
235
193
  return;
@@ -251,45 +209,6 @@ export class WebhookHandler {
251
209
  this.enqueueIssue(projectId, issueId);
252
210
  return wake.runType;
253
211
  }
254
- async hydrateIssueContext(projectId, normalized) {
255
- if (!normalized.issue)
256
- return normalized;
257
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted" && normalized.entityType !== "Issue") {
258
- return normalized;
259
- }
260
- if (normalized.entityType !== "Issue" && hasCompleteIssueContext(normalized.issue))
261
- return normalized;
262
- const linear = await this.linearProvider.forProject(projectId);
263
- if (!linear)
264
- return normalized;
265
- try {
266
- const liveIssue = await linear.getIssue(normalized.issue.id);
267
- return { ...normalized, issue: mergeIssueMetadata(normalized.issue, liveIssue) };
268
- }
269
- catch {
270
- return normalized;
271
- }
272
- }
273
- async tryHydrateProjectRoute(normalized) {
274
- if (!normalized.issue)
275
- return undefined;
276
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
277
- return undefined;
278
- for (const candidate of this.config.projects) {
279
- const linear = await this.linearProvider.forProject(candidate.id);
280
- if (!linear)
281
- continue;
282
- try {
283
- const liveIssue = await linear.getIssue(normalized.issue.id);
284
- const hydrated = { ...normalized, issue: mergeIssueMetadata(normalized.issue, liveIssue) };
285
- const resolved = resolveProject(this.config, hydrated.issue);
286
- if (resolved)
287
- return { project: resolved, normalized: hydrated };
288
- }
289
- catch { /* continue to next candidate */ }
290
- }
291
- return undefined;
292
- }
293
212
  isDirectReplyToOutstandingQuestion(issue) {
294
213
  if (!issue)
295
214
  return false;
@@ -46,13 +46,12 @@ export class AgentSessionHandler {
46
46
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
47
47
  return;
48
48
  }
49
- const blockerSummary = trackedIssue?.blockedByCount
50
- ? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
51
- : "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
52
- await this.publishAgentActivity(linear, normalized.agentSession.id, {
53
- type: "elicitation",
54
- body: blockerSummary,
55
- });
49
+ if (!trackedIssue?.blockedByCount) {
50
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
51
+ type: "elicitation",
52
+ body: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.",
53
+ });
54
+ }
56
55
  return;
57
56
  }
58
57
  if (normalized.triggerEvent === "agentSignal" && normalized.agentSession.signal === "stop") {