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