patchrelay 0.35.10 → 0.35.12

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.
Files changed (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +275 -74
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -2,10 +2,12 @@ import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
3
  import { TERMINAL_STATES } from "./factory-state.js";
4
4
  import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
5
+ import { deriveIssueStatusNote } from "./status-note.js";
5
6
  import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
6
7
  import { normalizeWebhook } from "./webhooks.js";
7
8
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
8
9
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
10
+ import { extractLatestAssistantSummary } from "./issue-session-events.js";
9
11
  export class WebhookHandler {
10
12
  config;
11
13
  db;
@@ -101,27 +103,51 @@ export class WebhookHandler {
101
103
  // Handle issue removal: release active runs, mark as failed.
102
104
  if (hydrated.triggerEvent === "issueRemoved" && trackedIssue) {
103
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
+ };
104
131
  if (removedIssue?.activeRunId) {
105
132
  const run = this.db.getRun(removedIssue.activeRunId);
106
133
  if (run) {
107
- this.db.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
134
+ await this.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
108
135
  }
109
- this.db.upsertIssue({
110
- projectId: project.id,
111
- linearIssueId: issue.id,
112
- activeRunId: null,
113
- pendingRunType: null,
114
- factoryState: "failed",
115
- });
116
136
  }
117
- else if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
118
- this.db.upsertIssue({
119
- projectId: project.id,
120
- linearIssueId: issue.id,
121
- pendingRunType: null,
122
- factoryState: "failed",
123
- });
137
+ if (activeLease) {
138
+ this.db.withIssueSessionLease(project.id, issue.id, activeLease.leaseId, commitRemoval);
124
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);
125
151
  this.feed?.publish({
126
152
  level: "warn",
127
153
  kind: "stage",
@@ -133,36 +159,39 @@ export class WebhookHandler {
133
159
  });
134
160
  }
135
161
  // Handle agent session events
136
- await this.handleAgentSession(hydrated, project, trackedIssue, result.desiredStage, result.delegated);
162
+ await this.handleAgentSession(hydrated, project, trackedIssue, result.wakeRunType, result.delegated);
137
163
  // Handle comments during active run
138
164
  await this.handleComment(hydrated, project, trackedIssue);
139
165
  this.db.markWebhookProcessed(webhookEventId, "processed");
140
- if (result.desiredStage) {
166
+ const wakeAlreadyQueuedByFollowUpHandler = normalized.triggerEvent === "commentCreated"
167
+ || normalized.triggerEvent === "commentUpdated"
168
+ || normalized.triggerEvent === "agentPrompted";
169
+ if (result.wakeRunType && !wakeAlreadyQueuedByFollowUpHandler) {
170
+ const queuedRunType = this.enqueuePendingSessionWake(project.id, issue.id);
141
171
  this.feed?.publish({
142
172
  level: "info",
143
173
  kind: "stage",
144
174
  issueKey: issue.identifier,
145
175
  projectId: project.id,
146
- stage: result.desiredStage,
176
+ stage: queuedRunType ?? result.wakeRunType,
147
177
  status: "queued",
148
- summary: `Queued ${result.desiredStage} workflow`,
178
+ summary: `Queued ${(queuedRunType ?? result.wakeRunType)} workflow`,
149
179
  detail: `Triggered by ${hydrated.triggerEvent}.`,
150
180
  });
151
- this.enqueueIssue(project.id, issue.id);
152
181
  }
153
182
  for (const dependentIssueId of newlyReadyDependents) {
154
183
  const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
184
+ const queuedRunType = this.enqueuePendingSessionWake(project.id, dependentIssueId);
155
185
  this.feed?.publish({
156
186
  level: "info",
157
187
  kind: "stage",
158
188
  issueKey: dependent?.issueKey,
159
189
  projectId: project.id,
160
- stage: "implementation",
190
+ stage: queuedRunType ?? "implementation",
161
191
  status: "queued",
162
- summary: "Queued implementation after blockers resolved",
192
+ summary: `Queued ${(queuedRunType ?? "implementation")} after blockers resolved`,
163
193
  detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
164
194
  });
165
- this.enqueueIssue(project.id, dependentIssueId);
166
195
  }
167
196
  }
168
197
  catch (error) {
@@ -183,26 +212,28 @@ export class WebhookHandler {
183
212
  async recordDesiredStage(project, normalized) {
184
213
  const normalizedIssue = normalized.issue;
185
214
  if (!normalizedIssue) {
186
- return { issue: undefined, desiredStage: undefined, delegated: false };
215
+ return { issue: undefined, wakeRunType: undefined, delegated: false };
187
216
  }
188
217
  // ── 1. Fetch data ────────────────────────────────────────────
189
218
  const existingIssue = this.db.getIssue(project.id, normalizedIssue.id);
190
219
  const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
191
220
  const delegated = this.isDelegatedToPatchRelay(project, normalized);
192
221
  const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
193
- if (!existingIssue && !delegated) {
194
- return { issue: undefined, desiredStage: undefined, delegated };
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 };
195
226
  }
196
227
  const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
197
228
  const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
198
- const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
199
229
  const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
200
230
  // ── 2. Pure decisions ────────────────────────────────────────
201
- const pendingRunType = decideRunIntent({
202
- delegated, triggerAllowed, unresolvedBlockers,
231
+ const desiredStage = decideRunIntent({
232
+ delegated, triggerAllowed, triggerEvent: normalized.triggerEvent, unresolvedBlockers,
203
233
  hasActiveRun: Boolean(activeRun),
204
- hasPendingRun: Boolean(existingIssue?.pendingRunType),
234
+ hasPendingWake,
205
235
  terminal,
236
+ currentState: existingIssue?.factoryState,
206
237
  });
207
238
  const runRelease = decideActiveRunRelease({
208
239
  hasActiveRun: Boolean(activeRun),
@@ -215,17 +246,20 @@ export class WebhookHandler {
215
246
  delegated,
216
247
  currentState: existingIssue?.factoryState,
217
248
  });
218
- const clearPending = (unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun)
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)
219
255
  || undelegation.clearPending;
220
256
  const agentSessionId = decideAgentSession({
221
257
  sessionId: normalized.agentSession?.id,
222
- hasActiveRun: Boolean(activeRun),
223
- hasPendingRun: Boolean(pendingRunType),
224
258
  triggerEvent: normalized.triggerEvent,
225
259
  delegated,
226
260
  });
227
261
  // ── 3. Transactional commit ──────────────────────────────────
228
- const issue = this.db.transaction(() => {
262
+ const commitIssueUpdate = () => {
229
263
  const record = this.db.upsertIssue({
230
264
  projectId: project.id,
231
265
  linearIssueId: normalizedIssue.id,
@@ -237,11 +271,10 @@ export class WebhookHandler {
237
271
  ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
238
272
  ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
239
273
  ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
240
- ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
241
- ...(clearPending ? { pendingRunType: null } : {}),
242
- ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
243
- ? { pendingRunContextJson }
244
- : {}),
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 } : {}),
245
278
  ...(agentSessionId !== undefined ? { agentSessionId } : {}),
246
279
  ...(runRelease.release ? { activeRunId: null } : {}),
247
280
  ...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
@@ -250,9 +283,28 @@ export class WebhookHandler {
250
283
  this.db.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
251
284
  }
252
285
  return record;
253
- });
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);
254
295
  // ── 4. Side effects (after transaction) ──────────────────────
255
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);
256
308
  this.feed?.publish({
257
309
  level: "warn",
258
310
  kind: "stage",
@@ -263,9 +315,25 @@ export class WebhookHandler {
263
315
  summary: "Issue un-delegated from PatchRelay",
264
316
  });
265
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
+ }
266
334
  return {
267
335
  issue: this.db.issueToTrackedIssue(issue),
268
- desiredStage: pendingRunType,
336
+ wakeRunType: this.peekPendingSessionWakeRunType(project.id, normalizedIssue.id),
269
337
  delegated,
270
338
  };
271
339
  }
@@ -314,29 +382,41 @@ export class WebhookHandler {
314
382
  }
315
383
  const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
316
384
  if (unresolved > 0) {
317
- if (issue.pendingRunType === "implementation" && issue.activeRunId === undefined) {
385
+ if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
386
+ && issue.activeRunId === undefined
387
+ && !this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
318
388
  this.db.upsertIssue({
319
389
  projectId,
320
390
  linearIssueId: dependent.linearIssueId,
321
391
  pendingRunType: null,
392
+ pendingRunContextJson: null,
322
393
  });
323
394
  }
324
395
  continue;
325
396
  }
326
- if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || issue.pendingRunType !== undefined) {
397
+ if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
327
398
  continue;
328
399
  }
329
- this.db.upsertIssue({
400
+ if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
401
+ this.db.upsertIssue({
402
+ projectId,
403
+ linearIssueId: dependent.linearIssueId,
404
+ pendingRunType: null,
405
+ pendingRunContextJson: null,
406
+ });
407
+ }
408
+ this.db.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
330
409
  projectId,
331
410
  linearIssueId: dependent.linearIssueId,
332
- pendingRunType: "implementation",
411
+ eventType: "delegated",
412
+ dedupeKey: `delegated:${dependent.linearIssueId}`,
333
413
  });
334
414
  newlyReady.push(dependent.linearIssueId);
335
415
  }
336
416
  return newlyReady;
337
417
  }
338
418
  // ─── Agent session handling (inlined) ─────────────────────────────
339
- async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
419
+ async handleAgentSession(normalized, project, trackedIssue, wakeRunType, delegated) {
340
420
  if (!normalized.agentSession?.id || !normalized.issue)
341
421
  return;
342
422
  const linear = await this.linearProvider.forProject(project.id);
@@ -346,14 +426,16 @@ export class WebhookHandler {
346
426
  const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
347
427
  if (normalized.triggerEvent === "agentSessionCreated") {
348
428
  if (!delegated) {
349
- const body = "PatchRelay received your mention. Delegate the issue to PatchRelay to start work.";
350
- await this.publishAgentActivity(linear, normalized.agentSession.id, { type: "elicitation", body });
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
+ }
351
433
  return;
352
434
  }
353
- if (desiredStage) {
435
+ if (wakeRunType) {
354
436
  const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
355
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: desiredStage });
356
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(desiredStage));
437
+ await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: wakeRunType });
438
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType));
357
439
  return;
358
440
  }
359
441
  if (activeRun) {
@@ -411,10 +493,32 @@ export class WebhookHandler {
411
493
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
412
494
  return;
413
495
  }
414
- if (desiredStage) {
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) {
415
519
  const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
416
- await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: desiredStage });
417
- await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(desiredStage, "prompt"), { ephemeral: true });
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 });
418
522
  }
419
523
  }
420
524
  // ─── Stop signal handling ────────────────────────────────────────
@@ -435,13 +539,21 @@ export class WebhookHandler {
435
539
  }
436
540
  this.db.finishRun(activeRun.id, { status: "released", threadId: activeRun.threadId, turnId: activeRun.turnId });
437
541
  }
438
- this.db.upsertIssue({
542
+ this.db.upsertIssueRespectingActiveLease(project.id, issueId, {
439
543
  projectId: project.id,
440
544
  linearIssueId: issueId,
441
545
  activeRunId: null,
442
546
  factoryState: "awaiting_input",
443
547
  agentSessionId: sessionId,
444
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);
445
557
  this.feed?.publish({
446
558
  level: "info",
447
559
  kind: "agent",
@@ -454,6 +566,16 @@ export class WebhookHandler {
454
566
  await this.publishAgentActivity(linear, sessionId, buildStopConfirmationActivity());
455
567
  await this.syncAgentSession(linear, sessionId, updatedIssue ?? trackedIssue);
456
568
  }
569
+ async stopActiveRun(run, input) {
570
+ if (!run.threadId || !run.turnId)
571
+ return;
572
+ try {
573
+ await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input });
574
+ }
575
+ catch (error) {
576
+ this.logger.warn({ runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run during session shutdown");
577
+ }
578
+ }
457
579
  // ─── Comment handling (inlined) ───────────────────────────────────
458
580
  async handleComment(normalized, project, trackedIssue) {
459
581
  if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
@@ -468,6 +590,15 @@ export class WebhookHandler {
468
590
  // commentCreated webhook back — without this guard that re-enqueues a new run.
469
591
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
470
592
  if (installation?.actorId && normalized.actor?.id === installation.actorId) {
593
+ this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
594
+ projectId: project.id,
595
+ linearIssueId: normalized.issue.id,
596
+ eventType: "self_comment",
597
+ eventJson: JSON.stringify({
598
+ body: normalized.comment.body.trim(),
599
+ author: normalized.comment.userName,
600
+ }),
601
+ });
471
602
  return;
472
603
  }
473
604
  const issue = this.db.getIssue(project.id, normalized.issue.id);
@@ -475,23 +606,30 @@ export class WebhookHandler {
475
606
  return;
476
607
  // No active run — enqueue a run with the comment as context if appropriate
477
608
  if (!issue.activeRunId) {
478
- const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated"]);
609
+ const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
479
610
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
480
611
  const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
481
- this.db.upsertIssue({
612
+ const hadPendingWake = this.db.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
613
+ const directReply = this.isDirectReplyToOutstandingQuestion(issue);
614
+ this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
482
615
  projectId: project.id,
483
616
  linearIssueId: normalized.issue.id,
484
- pendingRunType: runType,
485
- pendingRunContextJson: JSON.stringify({ userComment: normalized.comment.body.trim() }),
617
+ eventType: directReply ? "direct_reply" : "followup_comment",
618
+ eventJson: JSON.stringify({
619
+ body: normalized.comment.body.trim(),
620
+ author: normalized.comment.userName,
621
+ }),
486
622
  });
487
- this.enqueueIssue(project.id, normalized.issue.id);
623
+ const queuedRunType = hadPendingWake
624
+ ? this.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
625
+ : this.enqueuePendingSessionWake(project.id, normalized.issue.id);
488
626
  this.feed?.publish({
489
627
  level: "info",
490
628
  kind: "comment",
491
629
  projectId: project.id,
492
630
  issueKey: trackedIssue?.issueKey,
493
631
  status: "enqueued",
494
- summary: `Comment enqueued ${runType} run`,
632
+ summary: `Comment enqueued ${(queuedRunType ?? runType)} run`,
495
633
  detail: normalized.comment.body.slice(0, 200),
496
634
  });
497
635
  }
@@ -520,6 +658,20 @@ export class WebhookHandler {
520
658
  }
521
659
  catch (error) {
522
660
  this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
661
+ const hadPendingWake = this.db.hasPendingIssueSessionEvents(project.id, normalized.issue.id);
662
+ const directReply = this.isDirectReplyToOutstandingQuestion(issue);
663
+ this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
664
+ projectId: project.id,
665
+ linearIssueId: normalized.issue.id,
666
+ eventType: directReply ? "direct_reply" : "followup_comment",
667
+ eventJson: JSON.stringify({
668
+ body: normalized.comment.body.trim(),
669
+ author: normalized.comment.userName,
670
+ }),
671
+ });
672
+ if (!hadPendingWake) {
673
+ this.enqueuePendingSessionWake(project.id, normalized.issue.id);
674
+ }
523
675
  this.feed?.publish({
524
676
  level: "warn",
525
677
  kind: "comment",
@@ -531,6 +683,17 @@ export class WebhookHandler {
531
683
  });
532
684
  }
533
685
  }
686
+ peekPendingSessionWakeRunType(projectId, issueId) {
687
+ return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
688
+ }
689
+ enqueuePendingSessionWake(projectId, issueId) {
690
+ const wake = this.db.peekIssueSessionWake(projectId, issueId);
691
+ if (!wake) {
692
+ return undefined;
693
+ }
694
+ this.enqueueIssue(projectId, issueId);
695
+ return wake.runType;
696
+ }
534
697
  // ─── Helpers ──────────────────────────────────────────────────────
535
698
  async publishAgentActivity(linear, agentSessionId, content, options) {
536
699
  try {
@@ -544,6 +707,18 @@ export class WebhookHandler {
544
707
  this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to publish Linear agent activity");
545
708
  }
546
709
  }
710
+ async isCurrentLinearIssueDelegatedToPatchRelay(linear, projectId, issueId) {
711
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
712
+ if (!installation?.actorId)
713
+ return false;
714
+ try {
715
+ const issue = await linear.getIssue(issueId);
716
+ return issue.delegateId === installation.actorId;
717
+ }
718
+ catch {
719
+ return false;
720
+ }
721
+ }
547
722
  async syncAgentSession(linear, agentSessionId, issue, options) {
548
723
  if (!linear.updateAgentSession)
549
724
  return;
@@ -560,7 +735,7 @@ export class WebhookHandler {
560
735
  ? {
561
736
  plan: buildAgentSessionPlanForIssue({
562
737
  factoryState: issue.factoryState,
563
- pendingRunType: options?.pendingRunType ?? ("pendingRunType" in issue ? issue.pendingRunType : undefined),
738
+ pendingRunType: options?.pendingRunType ?? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId),
564
739
  ciRepairAttempts: "ciRepairAttempts" in issue ? issue.ciRepairAttempts : 0,
565
740
  queueRepairAttempts: "queueRepairAttempts" in issue ? issue.queueRepairAttempts : 0,
566
741
  }, options?.activeRunType ? { activeRunType: options.activeRunType } : undefined),
@@ -611,11 +786,40 @@ export class WebhookHandler {
611
786
  }
612
787
  return undefined;
613
788
  }
789
+ isDirectReplyToOutstandingQuestion(issue) {
790
+ if (!issue)
791
+ return false;
792
+ const linearNeedsInput = issue.currentLinearState?.trim().toLowerCase().includes("input") ?? false;
793
+ if (issue.factoryState !== "awaiting_input" && !linearNeedsInput)
794
+ return false;
795
+ if (issue.threadId) {
796
+ return true;
797
+ }
798
+ const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
799
+ const latestRunNote = extractLatestAssistantSummary(latestRun)?.trim();
800
+ if (latestRunNote?.endsWith("?")) {
801
+ return true;
802
+ }
803
+ const latestEvent = this.db.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
804
+ const statusNote = deriveIssueStatusNote({
805
+ issue,
806
+ latestRun,
807
+ latestEvent,
808
+ waitingReason: undefined,
809
+ })?.trim();
810
+ return Boolean(statusNote?.endsWith("?"));
811
+ }
614
812
  }
615
813
  // ─── Pure decision functions for recordDesiredStage ──────────────
616
814
  function decideRunIntent(p) {
617
- if (p.delegated && p.triggerAllowed && p.unresolvedBlockers === 0
618
- && !p.hasActiveRun && !p.hasPendingRun && !p.terminal) {
815
+ const wakeEligibleState = p.currentState === undefined
816
+ || p.currentState === "delegated"
817
+ || p.currentState === "awaiting_input";
818
+ const delegatedStartupRecovery = p.delegated
819
+ && p.currentState === "awaiting_input"
820
+ && p.triggerEvent === "issueCreated";
821
+ if (p.delegated && (p.triggerAllowed || delegatedStartupRecovery) && p.unresolvedBlockers === 0
822
+ && !p.hasActiveRun && !p.hasPendingWake && !p.terminal && wakeEligibleState) {
619
823
  return "implementation";
620
824
  }
621
825
  return undefined;
@@ -642,7 +846,7 @@ function decideUnDelegation(p) {
642
846
  function decideAgentSession(p) {
643
847
  if (p.sessionId)
644
848
  return p.sessionId;
645
- if (!p.hasActiveRun && (p.hasPendingRun || (p.triggerEvent === "delegateChanged" && !p.delegated)))
849
+ if (p.triggerEvent === "delegateChanged" && !p.delegated)
646
850
  return null;
647
851
  return undefined;
648
852
  }
@@ -654,7 +858,7 @@ function isTerminalDelegationState(existingIssue, hydratedIssue) {
654
858
  if (existingIssue?.prState === "merged") {
655
859
  return true;
656
860
  }
657
- if (existingIssue?.factoryState && TERMINAL_STATES.has(existingIssue.factoryState)) {
861
+ if (existingIssue?.factoryState && existingIssue.factoryState !== "awaiting_input" && TERMINAL_STATES.has(existingIssue.factoryState)) {
658
862
  return true;
659
863
  }
660
864
  return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
@@ -662,19 +866,6 @@ function isTerminalDelegationState(existingIssue, hydratedIssue) {
662
866
  function hasCompleteIssueContext(issue) {
663
867
  return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
664
868
  }
665
- function mergePendingImplementationContext(existingJson, normalized) {
666
- const existing = existingJson ? safeJsonParse(existingJson) ?? {} : {};
667
- const next = { ...existing };
668
- const promptContext = normalized.agentSession?.promptContext?.trim();
669
- const promptBody = normalized.agentSession?.promptBody?.trim();
670
- if (promptContext) {
671
- next.promptContext = promptContext;
672
- }
673
- if (promptBody) {
674
- next.promptBody = promptBody;
675
- }
676
- return Object.keys(next).length > 0 ? JSON.stringify(next) : undefined;
677
- }
678
869
  function mergeIssueMetadata(issue, liveIssue) {
679
870
  return {
680
871
  ...issue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.35.10",
3
+ "version": "0.35.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {