patchrelay 0.36.7 → 0.36.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
- import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
2
- import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
- import { TERMINAL_STATES } from "./factory-state.js";
4
- import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
5
1
  import { deriveIssueStatusNote } from "./status-note.js";
6
- import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
2
+ import { resolveProject, trustedActorAllowed } from "./project-resolution.js";
7
3
  import { normalizeWebhook } from "./webhooks.js";
8
4
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
5
+ import { AgentSessionHandler } from "./webhooks/agent-session-handler.js";
6
+ import { CommentWakeHandler } from "./webhooks/comment-wake-handler.js";
7
+ import { DesiredStageRecorder } from "./webhooks/desired-stage-recorder.js";
8
+ import { hasCompleteIssueContext, mergeIssueMetadata, } from "./webhooks/decision-helpers.js";
9
+ import { IssueRemovalHandler } from "./webhooks/issue-removal-handler.js";
9
10
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
10
11
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
11
12
  export class WebhookHandler {
@@ -17,6 +18,10 @@ export class WebhookHandler {
17
18
  logger;
18
19
  feed;
19
20
  installationHandler;
21
+ issueRemovalHandler;
22
+ commentWakeHandler;
23
+ agentSessionHandler;
24
+ desiredStageRecorder;
20
25
  constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
21
26
  this.config = config;
22
27
  this.db = db;
@@ -26,9 +31,13 @@ export class WebhookHandler {
26
31
  this.logger = logger;
27
32
  this.feed = feed;
28
33
  this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger);
34
+ this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
35
+ this.commentWakeHandler = new CommentWakeHandler(db, codex, logger, feed);
36
+ this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
37
+ this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
29
38
  }
30
39
  async processWebhookEvent(webhookEventId) {
31
- const event = this.db.getWebhookPayload(webhookEventId);
40
+ const event = this.db.webhookEvents.getWebhookPayload(webhookEventId);
32
41
  if (!event) {
33
42
  this.logger.warn({ webhookEventId }, "Webhook event was not found during processing");
34
43
  return;
@@ -36,7 +45,7 @@ export class WebhookHandler {
36
45
  try {
37
46
  const payload = safeJsonParse(event.payloadJson);
38
47
  if (!payload) {
39
- this.db.markWebhookProcessed(webhookEventId, "failed");
48
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
40
49
  throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
41
50
  }
42
51
  let normalized = normalizeWebhook({ webhookId: event.webhookId, payload });
@@ -54,7 +63,7 @@ export class WebhookHandler {
54
63
  summary: `Received ${normalized.triggerEvent} webhook`,
55
64
  });
56
65
  this.installationHandler.handle(normalized);
57
- this.db.markWebhookProcessed(webhookEventId, "processed");
66
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
58
67
  return;
59
68
  }
60
69
  let project = resolveProject(this.config, normalized.issue);
@@ -73,12 +82,12 @@ export class WebhookHandler {
73
82
  status: "ignored",
74
83
  summary: "Ignored webhook with no matching project route",
75
84
  });
76
- this.db.markWebhookProcessed(webhookEventId, "processed");
85
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
77
86
  return;
78
87
  }
79
88
  const routedIssue = normalized.issue;
80
89
  if (!routedIssue) {
81
- this.db.markWebhookProcessed(webhookEventId, "failed");
90
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
82
91
  throw new Error(`Issue context disappeared while routing webhook ${event.webhookId}`);
83
92
  }
84
93
  if (!trustedActorAllowed(project, normalized.actor)) {
@@ -90,79 +99,49 @@ export class WebhookHandler {
90
99
  status: "ignored",
91
100
  summary: "Ignored webhook from an untrusted actor",
92
101
  });
93
- this.db.markWebhookProcessed(webhookEventId, "processed");
102
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
94
103
  return;
95
104
  }
96
- this.db.assignWebhookProject(webhookEventId, project.id);
105
+ this.db.webhookEvents.assignWebhookProject(webhookEventId, project.id);
97
106
  const hydrated = await this.hydrateIssueContext(project.id, normalized);
98
107
  const issue = hydrated.issue ?? routedIssue;
99
108
  // Record desired stage and upsert issue
100
- const result = await this.recordDesiredStage(project, hydrated);
109
+ const result = await this.desiredStageRecorder.record({
110
+ project,
111
+ normalized: hydrated,
112
+ peekPendingSessionWakeRunType: (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId),
113
+ stopActiveRun: (run, input) => this.stopActiveRun(run, input),
114
+ });
101
115
  const trackedIssue = result.issue;
102
116
  const newlyReadyDependents = this.reconcileDependentReadiness(project.id, issue.id);
103
117
  // Handle issue removal: release active runs, mark as failed.
104
- if (hydrated.triggerEvent === "issueRemoved" && trackedIssue) {
105
- const removedIssue = this.db.getIssue(project.id, issue.id);
106
- const activeLease = this.db.getActiveIssueSessionLease(project.id, issue.id);
107
- const commitRemoval = () => {
108
- if (removedIssue?.activeRunId) {
109
- const run = this.db.getRun(removedIssue.activeRunId);
110
- if (run) {
111
- this.db.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
112
- }
113
- return this.db.upsertIssue({
114
- projectId: project.id,
115
- linearIssueId: issue.id,
116
- activeRunId: null,
117
- pendingRunType: null,
118
- factoryState: "failed",
119
- });
120
- }
121
- if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
122
- return this.db.upsertIssue({
123
- projectId: project.id,
124
- linearIssueId: issue.id,
125
- pendingRunType: null,
126
- factoryState: "failed",
127
- });
128
- }
129
- return removedIssue;
130
- };
131
- if (removedIssue?.activeRunId) {
132
- const run = this.db.getRun(removedIssue.activeRunId);
133
- if (run) {
134
- await this.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
135
- }
136
- }
137
- if (activeLease) {
138
- this.db.withIssueSessionLease(project.id, issue.id, activeLease.leaseId, commitRemoval);
139
- }
140
- else {
141
- commitRemoval();
142
- }
143
- this.db.appendIssueSessionEvent({
144
- projectId: project.id,
145
- linearIssueId: issue.id,
146
- eventType: "issue_removed",
147
- dedupeKey: `issue_removed:${issue.id}`,
148
- });
149
- this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, issue.id);
150
- this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, issue.id);
151
- this.feed?.publish({
152
- level: "warn",
153
- kind: "stage",
154
- issueKey: issue.identifier,
118
+ if (hydrated.triggerEvent === "issueRemoved") {
119
+ await this.issueRemovalHandler.handle({
155
120
  projectId: project.id,
156
- stage: "failed",
157
- status: "issue_removed",
158
- summary: "Issue removed from Linear",
121
+ issue,
122
+ trackedIssue,
123
+ stopActiveRun: (run, input) => this.stopActiveRun(run, input),
159
124
  });
160
125
  }
161
- // Handle agent session events
162
- await this.handleAgentSession(hydrated, project, trackedIssue, result.wakeRunType, result.delegated);
163
- // Handle comments during active run
164
- await this.handleComment(hydrated, project, trackedIssue);
165
- this.db.markWebhookProcessed(webhookEventId, "processed");
126
+ await this.agentSessionHandler.handle({
127
+ normalized: hydrated,
128
+ project,
129
+ trackedIssue,
130
+ wakeRunType: result.wakeRunType,
131
+ delegated: result.delegated,
132
+ peekPendingSessionWakeRunType: (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId),
133
+ enqueuePendingSessionWake: (projectId, issueId) => this.enqueuePendingSessionWake(projectId, issueId),
134
+ isDirectReplyToOutstandingQuestion: (targetIssue) => this.isDirectReplyToOutstandingQuestion(targetIssue),
135
+ });
136
+ await this.commentWakeHandler.handle({
137
+ normalized: hydrated,
138
+ project,
139
+ trackedIssue,
140
+ enqueuePendingSessionWake: (projectId, issueId) => this.enqueuePendingSessionWake(projectId, issueId),
141
+ peekPendingSessionWakeRunType: (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId),
142
+ isDirectReplyToOutstandingQuestion: (targetIssue) => this.isDirectReplyToOutstandingQuestion(targetIssue),
143
+ });
144
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
166
145
  const wakeAlreadyQueuedByFollowUpHandler = normalized.triggerEvent === "commentCreated"
167
146
  || normalized.triggerEvent === "commentUpdated"
168
147
  || normalized.triggerEvent === "agentPrompted";
@@ -195,7 +174,7 @@ export class WebhookHandler {
195
174
  }
196
175
  }
197
176
  catch (error) {
198
- this.db.markWebhookProcessed(webhookEventId, "failed");
177
+ this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
199
178
  const err = error instanceof Error ? error : new Error(String(error));
200
179
  this.feed?.publish({
201
180
  level: "error",
@@ -209,170 +188,6 @@ export class WebhookHandler {
209
188
  throw err;
210
189
  }
211
190
  }
212
- async recordDesiredStage(project, normalized) {
213
- const normalizedIssue = normalized.issue;
214
- if (!normalizedIssue) {
215
- return { issue: undefined, wakeRunType: undefined, delegated: false };
216
- }
217
- // ── 1. Fetch data ────────────────────────────────────────────
218
- const existingIssue = this.db.getIssue(project.id, normalizedIssue.id);
219
- const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
220
- const delegated = this.isDelegatedToPatchRelay(project, normalized);
221
- const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
222
- const incomingAgentSessionId = normalized.agentSession?.id;
223
- const hasPendingWake = this.db.peekIssueSessionWake(project.id, normalizedIssue.id) !== undefined;
224
- if (!existingIssue && !delegated && !incomingAgentSessionId) {
225
- return { issue: undefined, wakeRunType: undefined, delegated };
226
- }
227
- const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
228
- const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
229
- const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
230
- // ── 2. Pure decisions ────────────────────────────────────────
231
- const desiredStage = decideRunIntent({
232
- delegated, triggerAllowed, triggerEvent: normalized.triggerEvent, unresolvedBlockers,
233
- hasActiveRun: Boolean(activeRun),
234
- hasPendingWake,
235
- terminal,
236
- currentState: existingIssue?.factoryState,
237
- });
238
- const runRelease = decideActiveRunRelease({
239
- hasActiveRun: Boolean(activeRun),
240
- terminal,
241
- triggerEvent: normalized.triggerEvent,
242
- delegated,
243
- });
244
- const undelegation = decideUnDelegation({
245
- triggerEvent: normalized.triggerEvent,
246
- delegated,
247
- currentState: existingIssue?.factoryState,
248
- });
249
- const delegatedStateRecovery = delegated
250
- && !terminal
251
- && existingIssue?.factoryState === "awaiting_input"
252
- && !undelegation.factoryState;
253
- const existingWakeRunType = existingIssue ? this.peekPendingSessionWakeRunType(project.id, normalizedIssue.id) : undefined;
254
- const clearPending = (unresolvedBlockers > 0 && existingWakeRunType === "implementation" && !activeRun)
255
- || undelegation.clearPending;
256
- const agentSessionId = decideAgentSession({
257
- sessionId: normalized.agentSession?.id,
258
- triggerEvent: normalized.triggerEvent,
259
- delegated,
260
- });
261
- // ── 3. Transactional commit ──────────────────────────────────
262
- const commitIssueUpdate = () => {
263
- const record = this.db.upsertIssue({
264
- projectId: project.id,
265
- linearIssueId: normalizedIssue.id,
266
- ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
267
- ...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
268
- ...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
269
- ...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
270
- ...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
271
- ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
272
- ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
273
- ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
274
- ...(!existingIssue && !delegated && incomingAgentSessionId ? { factoryState: "awaiting_input" } : {}),
275
- ...(delegatedStateRecovery ? { factoryState: "delegated" } : {}),
276
- ...(desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
277
- ...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
278
- ...(agentSessionId !== undefined ? { agentSessionId } : {}),
279
- ...(runRelease.release ? { activeRunId: null } : {}),
280
- ...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
281
- });
282
- if (runRelease.release && activeRun && runRelease.reason) {
283
- this.db.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
284
- }
285
- return record;
286
- };
287
- const activeLease = this.db.getActiveIssueSessionLease(project.id, normalizedIssue.id);
288
- const issue = activeLease
289
- ? this.db.withIssueSessionLease(project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.upsertIssue({
290
- projectId: project.id,
291
- linearIssueId: normalizedIssue.id,
292
- ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
293
- }))
294
- : this.db.transaction(commitIssueUpdate);
295
- // ── 4. Side effects (after transaction) ──────────────────────
296
- if (undelegation.factoryState) {
297
- if (activeRun?.threadId && activeRun.turnId) {
298
- await this.stopActiveRun(activeRun, "STOP: The issue was un-delegated from PatchRelay. Stop working immediately and exit.");
299
- }
300
- this.db.appendIssueSessionEvent({
301
- projectId: project.id,
302
- linearIssueId: normalizedIssue.id,
303
- eventType: "undelegated",
304
- dedupeKey: `undelegated:${normalizedIssue.id}`,
305
- });
306
- this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalizedIssue.id);
307
- this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, normalizedIssue.id);
308
- this.feed?.publish({
309
- level: "warn",
310
- kind: "stage",
311
- issueKey: issue.issueKey,
312
- projectId: project.id,
313
- stage: "awaiting_input",
314
- status: "un_delegated",
315
- summary: "Issue un-delegated from PatchRelay",
316
- });
317
- }
318
- else if (desiredStage === "implementation"
319
- && normalized.triggerEvent !== "commentCreated"
320
- && normalized.triggerEvent !== "commentUpdated"
321
- && normalized.triggerEvent !== "agentPrompted") {
322
- this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalizedIssue.id, {
323
- projectId: project.id,
324
- linearIssueId: normalizedIssue.id,
325
- eventType: "delegated",
326
- eventJson: JSON.stringify({
327
- promptContext: normalized.agentSession?.promptContext?.trim()
328
- ?? (issue.issueKey ? `Linear issue ${issue.issueKey} was delegated to PatchRelay.` : undefined),
329
- promptBody: normalized.agentSession?.promptBody?.trim(),
330
- }),
331
- dedupeKey: `delegated:${normalizedIssue.id}`,
332
- });
333
- }
334
- return {
335
- issue: this.db.issueToTrackedIssue(issue),
336
- wakeRunType: this.peekPendingSessionWakeRunType(project.id, normalizedIssue.id),
337
- delegated,
338
- };
339
- }
340
- isDelegatedToPatchRelay(project, normalized) {
341
- if (!normalized.issue)
342
- return false;
343
- const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
344
- if (!installation?.actorId)
345
- return false;
346
- return normalized.issue.delegateId === installation.actorId;
347
- }
348
- async syncIssueDependencies(projectId, issue) {
349
- let source = issue;
350
- if (!source.relationsKnown) {
351
- const linear = await this.linearProvider.forProject(projectId);
352
- if (linear) {
353
- try {
354
- source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
355
- }
356
- catch {
357
- // Preserve existing dependency rows when webhook relation data is incomplete.
358
- }
359
- }
360
- }
361
- if (source.relationsKnown) {
362
- this.db.replaceIssueDependencies({
363
- projectId,
364
- linearIssueId: source.id,
365
- blockers: source.blockedBy.map((blocker) => ({
366
- blockerLinearIssueId: blocker.id,
367
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
368
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
369
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
370
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
371
- })),
372
- });
373
- }
374
- return source;
375
- }
376
191
  reconcileDependentReadiness(projectId, blockerLinearIssueId) {
377
192
  const newlyReady = [];
378
193
  for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
@@ -384,7 +199,7 @@ export class WebhookHandler {
384
199
  if (unresolved > 0) {
385
200
  if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
386
201
  && issue.activeRunId === undefined
387
- && !this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
202
+ && !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
388
203
  this.db.upsertIssue({
389
204
  projectId,
390
205
  linearIssueId: dependent.linearIssueId,
@@ -394,7 +209,7 @@ export class WebhookHandler {
394
209
  }
395
210
  continue;
396
211
  }
397
- if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
212
+ if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
398
213
  continue;
399
214
  }
400
215
  if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
@@ -405,7 +220,7 @@ export class WebhookHandler {
405
220
  pendingRunContextJson: null,
406
221
  });
407
222
  }
408
- this.db.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
223
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
409
224
  projectId,
410
225
  linearIssueId: dependent.linearIssueId,
411
226
  eventType: "delegated",
@@ -415,157 +230,6 @@ export class WebhookHandler {
415
230
  }
416
231
  return newlyReady;
417
232
  }
418
- // ─── Agent session handling (inlined) ─────────────────────────────
419
- async handleAgentSession(normalized, project, trackedIssue, wakeRunType, delegated) {
420
- if (!normalized.agentSession?.id || !normalized.issue)
421
- return;
422
- const linear = await this.linearProvider.forProject(project.id);
423
- if (!linear)
424
- return;
425
- const existingIssue = this.db.getIssue(project.id, normalized.issue.id);
426
- const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
427
- if (normalized.triggerEvent === "agentSessionCreated") {
428
- if (!delegated) {
429
- const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
430
- if (latestIssue ?? trackedIssue) {
431
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue);
432
- }
433
- return;
434
- }
435
- if (wakeRunType) {
436
- const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
437
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: wakeRunType });
438
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType));
439
- return;
440
- }
441
- if (activeRun) {
442
- const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
443
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { activeRunType: activeRun.runType });
444
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
445
- return;
446
- }
447
- const blockerSummary = trackedIssue?.blockedByCount
448
- ? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
449
- : "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
450
- await this.publishAgentActivity(linear, normalized.agentSession.id, {
451
- type: "elicitation",
452
- body: blockerSummary,
453
- });
454
- return;
455
- }
456
- // Stop signal — halt active work and confirm disengagement
457
- if (normalized.triggerEvent === "agentSignal" && normalized.agentSession.signal === "stop") {
458
- await this.handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear);
459
- return;
460
- }
461
- if (normalized.triggerEvent !== "agentPrompted")
462
- return;
463
- if (!triggerEventAllowed(project, normalized.triggerEvent))
464
- return;
465
- const promptBody = normalized.agentSession.promptBody?.trim();
466
- if (activeRun && promptBody && activeRun.threadId && activeRun.turnId) {
467
- // Deliver prompt directly to active Codex turn
468
- const input = `New Linear agent prompt received while you are working.\n\n${promptBody}`;
469
- try {
470
- await this.codex.steerTurn({ threadId: activeRun.threadId, turnId: activeRun.turnId, input });
471
- this.feed?.publish({
472
- level: "info",
473
- kind: "agent",
474
- projectId: project.id,
475
- issueKey: trackedIssue?.issueKey,
476
- stage: activeRun.runType,
477
- status: "delivered",
478
- summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
479
- });
480
- }
481
- catch (error) {
482
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up prompt");
483
- this.feed?.publish({
484
- level: "warn",
485
- kind: "agent",
486
- projectId: project.id,
487
- issueKey: trackedIssue?.issueKey,
488
- stage: activeRun.runType,
489
- status: "delivery_failed",
490
- summary: `Could not deliver follow-up prompt to active ${activeRun.runType} workflow`,
491
- });
492
- }
493
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
494
- return;
495
- }
496
- if (promptBody && existingIssue && (delegated || existingIssue.factoryState === "awaiting_input")) {
497
- const hadPendingWake = this.db.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
498
- const directReply = this.isDirectReplyToOutstandingQuestion(existingIssue);
499
- this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
500
- projectId: project.id,
501
- linearIssueId: normalized.issue.id,
502
- eventType: directReply ? "direct_reply" : "followup_prompt",
503
- eventJson: JSON.stringify({
504
- text: promptBody,
505
- source: "linear_agent_prompt",
506
- }),
507
- });
508
- const queuedRunType = hadPendingWake
509
- ? this.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
510
- : this.enqueuePendingSessionWake(project.id, normalized.issue.id);
511
- const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
512
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, {
513
- pendingRunType: queuedRunType ?? wakeRunType ?? (existingIssue.prReviewState === "changes_requested" ? "review_fix" : "implementation"),
514
- });
515
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(queuedRunType ?? wakeRunType ?? "implementation"), { ephemeral: true });
516
- return;
517
- }
518
- if (wakeRunType) {
519
- const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
520
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: wakeRunType });
521
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
522
- }
523
- }
524
- // ─── Stop signal handling ────────────────────────────────────────
525
- async handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear) {
526
- const issueId = normalized.issue.id;
527
- const sessionId = normalized.agentSession.id;
528
- // Best-effort halt: steer the active Codex turn with a stop instruction
529
- if (activeRun?.threadId && activeRun.turnId) {
530
- try {
531
- await this.codex.steerTurn({
532
- threadId: activeRun.threadId,
533
- turnId: activeRun.turnId,
534
- input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
535
- });
536
- }
537
- catch (error) {
538
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
539
- }
540
- this.db.finishRun(activeRun.id, { status: "released", threadId: activeRun.threadId, turnId: activeRun.turnId });
541
- }
542
- this.db.upsertIssueRespectingActiveLease(project.id, issueId, {
543
- projectId: project.id,
544
- linearIssueId: issueId,
545
- activeRunId: null,
546
- factoryState: "awaiting_input",
547
- agentSessionId: sessionId,
548
- });
549
- this.db.appendIssueSessionEvent({
550
- projectId: project.id,
551
- linearIssueId: issueId,
552
- eventType: "stop_requested",
553
- dedupeKey: `stop_requested:${issueId}`,
554
- });
555
- this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, issueId);
556
- this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, issueId);
557
- this.feed?.publish({
558
- level: "info",
559
- kind: "agent",
560
- projectId: project.id,
561
- issueKey: trackedIssue?.issueKey,
562
- status: "stopped",
563
- summary: "Stop signal received — work halted",
564
- });
565
- const updatedIssue = this.db.getIssue(project.id, issueId);
566
- await this.publishAgentActivity(linear, sessionId, buildStopConfirmationActivity());
567
- await this.syncAgentSession(linear, sessionId, updatedIssue ?? trackedIssue);
568
- }
569
233
  async stopActiveRun(run, input) {
570
234
  if (!run.threadId || !run.turnId)
571
235
  return;
@@ -576,235 +240,17 @@ export class WebhookHandler {
576
240
  this.logger.warn({ runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run during session shutdown");
577
241
  }
578
242
  }
579
- // ─── Comment handling (inlined) ───────────────────────────────────
580
- async handleComment(normalized, project, trackedIssue) {
581
- if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
582
- !normalized.comment?.body ||
583
- !normalized.issue) {
584
- return;
585
- }
586
- if (!triggerEventAllowed(project, normalized.triggerEvent))
587
- return;
588
- const issue = this.db.getIssue(project.id, normalized.issue.id);
589
- if (!issue)
590
- return;
591
- const trimmedBody = normalized.comment.body.trim();
592
- // Ignore PatchRelay-managed comments to prevent status-sync feedback loops.
593
- // Linear commentUpdated/commentCreated events can arrive after PatchRelay
594
- // refreshes its visible status comment, and those updates should never
595
- // consume review-fix budget or wake a new run.
596
- const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
597
- const selfAuthored = this.isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
598
- const inertPatchRelayComment = this.isInertPatchRelayComment(issue, normalized.comment.id, trimmedBody, normalized.actor?.type);
599
- if (selfAuthored || inertPatchRelayComment) {
600
- this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
601
- projectId: project.id,
602
- linearIssueId: normalized.issue.id,
603
- eventType: "self_comment",
604
- eventJson: JSON.stringify({
605
- body: trimmedBody,
606
- author: normalized.comment.userName,
607
- }),
608
- });
609
- return;
610
- }
611
- // No active run — enqueue a run with the comment as context if appropriate
612
- if (!issue.activeRunId) {
613
- const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
614
- if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
615
- const directReply = this.isDirectReplyToOutstandingQuestion(issue);
616
- const wakeIntent = directReply || this.hasExplicitPatchRelayWakeIntent(trimmedBody);
617
- if (!wakeIntent) {
618
- this.feed?.publish({
619
- level: "info",
620
- kind: "comment",
621
- projectId: project.id,
622
- issueKey: trackedIssue?.issueKey,
623
- status: "ignored",
624
- summary: "Ignored comment with no explicit PatchRelay wake intent",
625
- detail: trimmedBody.slice(0, 200),
626
- });
627
- return;
628
- }
629
- const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
630
- const hadPendingWake = this.db.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
631
- this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
632
- projectId: project.id,
633
- linearIssueId: normalized.issue.id,
634
- eventType: directReply ? "direct_reply" : "followup_comment",
635
- eventJson: JSON.stringify({
636
- body: trimmedBody,
637
- author: normalized.comment.userName,
638
- }),
639
- });
640
- const queuedRunType = hadPendingWake
641
- ? this.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
642
- : this.enqueuePendingSessionWake(project.id, normalized.issue.id);
643
- this.feed?.publish({
644
- level: "info",
645
- kind: "comment",
646
- projectId: project.id,
647
- issueKey: trackedIssue?.issueKey,
648
- status: "enqueued",
649
- summary: `Comment enqueued ${(queuedRunType ?? runType)} run`,
650
- detail: trimmedBody.slice(0, 200),
651
- });
652
- }
653
- return;
654
- }
655
- const run = this.db.getRun(issue.activeRunId);
656
- if (!run?.threadId || !run.turnId)
657
- return;
658
- const body = [
659
- "New Linear comment received while you are working.",
660
- normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
661
- "",
662
- trimmedBody,
663
- ].filter(Boolean).join("\n");
664
- try {
665
- await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: body });
666
- this.feed?.publish({
667
- level: "info",
668
- kind: "comment",
669
- projectId: project.id,
670
- issueKey: trackedIssue?.issueKey,
671
- stage: run.runType,
672
- status: "delivered",
673
- summary: `Delivered follow-up comment to active ${run.runType} workflow`,
674
- });
675
- }
676
- catch (error) {
677
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
678
- const hadPendingWake = this.db.hasPendingIssueSessionEvents(project.id, normalized.issue.id);
679
- const directReply = this.isDirectReplyToOutstandingQuestion(issue);
680
- this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
681
- projectId: project.id,
682
- linearIssueId: normalized.issue.id,
683
- eventType: directReply ? "direct_reply" : "followup_comment",
684
- eventJson: JSON.stringify({
685
- body: trimmedBody,
686
- author: normalized.comment.userName,
687
- }),
688
- });
689
- if (!hadPendingWake) {
690
- this.enqueuePendingSessionWake(project.id, normalized.issue.id);
691
- }
692
- this.feed?.publish({
693
- level: "warn",
694
- kind: "comment",
695
- projectId: project.id,
696
- issueKey: trackedIssue?.issueKey,
697
- stage: run.runType,
698
- status: "delivery_failed",
699
- summary: `Could not deliver follow-up comment to active ${run.runType} workflow`,
700
- });
701
- }
702
- }
703
- isInertPatchRelayComment(issue, commentId, body, actorType) {
704
- if (commentId === issue.statusCommentId) {
705
- return true;
706
- }
707
- if (body.startsWith("## PatchRelay status")
708
- && body.includes("_PatchRelay updates this comment as it works. Review and merge remain downstream._")) {
709
- return true;
710
- }
711
- const normalizedActorType = actorType?.trim().toLowerCase();
712
- if (normalizedActorType && normalizedActorType !== "user") {
713
- return this.isPatchRelayGeneratedActivityComment(body);
714
- }
715
- return false;
716
- }
717
- isPatchRelayManagedCommentAuthor(installation, actor, commentUserName) {
718
- const actorName = actor?.name?.trim().toLowerCase();
719
- const commentAuthor = commentUserName?.trim().toLowerCase();
720
- const installationName = installation?.actorName?.trim().toLowerCase();
721
- if (installation?.actorId && actor?.id === installation.actorId) {
722
- return true;
723
- }
724
- if (installationName && actorName === installationName) {
725
- return true;
726
- }
727
- if (actorName === "patchrelay" || commentAuthor === "patchrelay") {
728
- return true;
729
- }
730
- return false;
731
- }
732
- isPatchRelayGeneratedActivityComment(body) {
733
- return body.startsWith("PatchRelay needs human help to continue.")
734
- || body.startsWith("PatchRelay is already working on ")
735
- || body.startsWith("PatchRelay received the ")
736
- || body.startsWith("PatchRelay routed your latest instructions into ")
737
- || body.startsWith("PatchRelay has stopped work as requested.")
738
- || body.startsWith("Merge preparation failed ")
739
- || body === "This thread is for an agent session with patchrelay.";
740
- }
741
- hasExplicitPatchRelayWakeIntent(body) {
742
- return /\bpatchrelay\b/i.test(body);
743
- }
744
243
  peekPendingSessionWakeRunType(projectId, issueId) {
745
- return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
244
+ return this.db.issueSessions.peekIssueSessionWake(projectId, issueId)?.runType;
746
245
  }
747
246
  enqueuePendingSessionWake(projectId, issueId) {
748
- const wake = this.db.peekIssueSessionWake(projectId, issueId);
247
+ const wake = this.db.issueSessions.peekIssueSessionWake(projectId, issueId);
749
248
  if (!wake) {
750
249
  return undefined;
751
250
  }
752
251
  this.enqueueIssue(projectId, issueId);
753
252
  return wake.runType;
754
253
  }
755
- // ─── Helpers ──────────────────────────────────────────────────────
756
- async publishAgentActivity(linear, agentSessionId, content, options) {
757
- try {
758
- await linear.createAgentActivity({
759
- agentSessionId,
760
- content,
761
- ephemeral: options?.ephemeral ?? content.type === "thought",
762
- });
763
- }
764
- catch (error) {
765
- this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to publish Linear agent activity");
766
- }
767
- }
768
- async isCurrentLinearIssueDelegatedToPatchRelay(linear, projectId, issueId) {
769
- const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
770
- if (!installation?.actorId)
771
- return false;
772
- try {
773
- const issue = await linear.getIssue(issueId);
774
- return issue.delegateId === installation.actorId;
775
- }
776
- catch {
777
- return false;
778
- }
779
- }
780
- async syncAgentSession(linear, agentSessionId, issue, options) {
781
- if (!linear.updateAgentSession)
782
- return;
783
- try {
784
- const prUrl = issue && "prUrl" in issue ? issue.prUrl : undefined;
785
- const externalUrls = buildAgentSessionExternalUrls(this.config, {
786
- ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
787
- ...(prUrl ? { prUrl } : {}),
788
- });
789
- await linear.updateAgentSession({
790
- agentSessionId,
791
- ...(externalUrls ? { externalUrls } : {}),
792
- ...(issue
793
- ? {
794
- plan: buildAgentSessionPlanForIssue({
795
- factoryState: issue.factoryState,
796
- pendingRunType: options?.pendingRunType ?? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId),
797
- ciRepairAttempts: "ciRepairAttempts" in issue ? issue.ciRepairAttempts : 0,
798
- queueRepairAttempts: "queueRepairAttempts" in issue ? issue.queueRepairAttempts : 0,
799
- }, options?.activeRunType ? { activeRunType: options.activeRunType } : undefined),
800
- }
801
- : {}),
802
- });
803
- }
804
- catch (error) {
805
- this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to update Linear agent session");
806
- }
807
- }
808
254
  async hydrateIssueContext(projectId, normalized) {
809
255
  if (!normalized.issue)
810
256
  return normalized;
@@ -853,12 +299,12 @@ export class WebhookHandler {
853
299
  if (issue.threadId) {
854
300
  return true;
855
301
  }
856
- const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
302
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
857
303
  const latestRunNote = extractLatestAssistantSummary(latestRun)?.trim();
858
304
  if (latestRunNote?.endsWith("?")) {
859
305
  return true;
860
306
  }
861
- const latestEvent = this.db.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
307
+ const latestEvent = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
862
308
  const statusNote = deriveIssueStatusNote({
863
309
  issue,
864
310
  latestRun,
@@ -868,78 +314,3 @@ export class WebhookHandler {
868
314
  return Boolean(statusNote?.endsWith("?"));
869
315
  }
870
316
  }
871
- // ─── Pure decision functions for recordDesiredStage ──────────────
872
- function decideRunIntent(p) {
873
- const wakeEligibleState = p.currentState === undefined
874
- || p.currentState === "delegated"
875
- || p.currentState === "awaiting_input";
876
- const delegatedStartupRecovery = p.delegated
877
- && p.currentState === "awaiting_input"
878
- && p.triggerEvent === "issueCreated";
879
- if (p.delegated && (p.triggerAllowed || delegatedStartupRecovery) && p.unresolvedBlockers === 0
880
- && !p.hasActiveRun && !p.hasPendingWake && !p.terminal && wakeEligibleState) {
881
- return "implementation";
882
- }
883
- return undefined;
884
- }
885
- function decideActiveRunRelease(p) {
886
- if (!p.hasActiveRun)
887
- return { release: false };
888
- if (p.terminal)
889
- return { release: true, reason: "Issue reached terminal state during active run" };
890
- if (p.triggerEvent === "delegateChanged" && !p.delegated)
891
- return { release: true, reason: "Un-delegated from PatchRelay" };
892
- return { release: false };
893
- }
894
- function decideUnDelegation(p) {
895
- if (p.triggerEvent !== "delegateChanged" || p.delegated)
896
- return { clearPending: false };
897
- if (!p.currentState)
898
- return { clearPending: false };
899
- const pastNoReturn = p.currentState === "awaiting_queue" || TERMINAL_STATES.has(p.currentState);
900
- if (pastNoReturn)
901
- return { clearPending: false };
902
- return { factoryState: "awaiting_input", clearPending: true };
903
- }
904
- function decideAgentSession(p) {
905
- if (p.sessionId)
906
- return p.sessionId;
907
- if (p.triggerEvent === "delegateChanged" && !p.delegated)
908
- return null;
909
- return undefined;
910
- }
911
- // ─── Helper predicates ──────────────────────────────────────────
912
- function isResolvedLinearState(stateType, stateName) {
913
- return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
914
- }
915
- function isTerminalDelegationState(existingIssue, hydratedIssue) {
916
- if (existingIssue?.prState === "merged") {
917
- return true;
918
- }
919
- if (existingIssue?.factoryState && existingIssue.factoryState !== "awaiting_input" && TERMINAL_STATES.has(existingIssue.factoryState)) {
920
- return true;
921
- }
922
- return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
923
- }
924
- function hasCompleteIssueContext(issue) {
925
- return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
926
- }
927
- function mergeIssueMetadata(issue, liveIssue) {
928
- return {
929
- ...issue,
930
- ...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
931
- ...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
932
- ...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
933
- ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
934
- ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
935
- ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
936
- ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
937
- ...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
938
- ...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
939
- ...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
940
- relationsKnown: issue.relationsKnown || liveIssue.blockedBy !== undefined || liveIssue.blocks !== undefined,
941
- labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
942
- blockedBy: issue.relationsKnown ? issue.blockedBy : (liveIssue.blockedBy ?? issue.blockedBy),
943
- blocks: issue.relationsKnown ? issue.blocks : (liveIssue.blocks ?? issue.blocks),
944
- };
945
- }