patchrelay 0.35.11 → 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 +268 -76
  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
@@ -1,3 +1,4 @@
1
+ import { getThreadTurns } from "./codex-thread-utils.js";
1
2
  export function extractStageSummary(report) {
2
3
  return {
3
4
  assistantMessageCount: report.assistantMessages.length,
@@ -8,7 +9,8 @@ export function extractStageSummary(report) {
8
9
  };
9
10
  }
10
11
  export function summarizeCurrentThread(thread) {
11
- const latestTurn = thread.turns.at(-1);
12
+ const turns = getThreadTurns(thread);
13
+ const latestTurn = turns.at(-1);
12
14
  const latestAgentMessage = latestTurn?.items
13
15
  .filter((item) => item.type === "agentMessage")
14
16
  .at(-1)?.text;
@@ -25,7 +27,7 @@ export function summarizeCurrentThread(thread) {
25
27
  let commandCount = 0;
26
28
  let fileChangeCount = 0;
27
29
  let toolCallCount = 0;
28
- for (const turn of thread.turns) {
30
+ for (const turn of turns) {
29
31
  for (const item of turn.items) {
30
32
  if (item.type === "commandExecution") {
31
33
  commandCount += 1;
@@ -57,7 +59,7 @@ export function buildStageReport(run, issue, thread, eventCounts) {
57
59
  const commands = [];
58
60
  const fileChanges = [];
59
61
  const toolCalls = [];
60
- for (const turn of thread.turns) {
62
+ for (const turn of getThreadTurns(thread)) {
61
63
  for (const rawItem of turn.items) {
62
64
  const item = rawItem;
63
65
  if (item.type === "agentMessage" && typeof item.text === "string") {
package/dist/service.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
2
2
  import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
3
+ import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
4
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
4
5
  import { IssueQueryService } from "./issue-query-service.js";
5
6
  import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
6
7
  import { LinearOAuthService } from "./linear-oauth-service.js";
7
8
  import { RunOrchestrator } from "./run-orchestrator.js";
8
9
  import { OperatorEventFeed } from "./operator-feed.js";
10
+ import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
9
11
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
10
12
  import { ServiceRuntime } from "./service-runtime.js";
11
13
  import { WebhookHandler } from "./webhook-handler.js";
12
14
  import { acceptIncomingWebhook } from "./service-webhooks.js";
15
+ import { deriveIssueStatusNote } from "./status-note.js";
13
16
  function parseObjectJson(value) {
14
17
  if (!value)
15
18
  return undefined;
@@ -21,19 +24,14 @@ function parseObjectJson(value) {
21
24
  return undefined;
22
25
  }
23
26
  }
24
- function extractStatusNote(summaryJson, reportJson) {
25
- const summary = parseObjectJson(summaryJson);
26
- if (typeof summary?.latestAssistantMessage === "string" && summary.latestAssistantMessage.trim()) {
27
- return summary.latestAssistantMessage;
28
- }
29
- const report = parseObjectJson(reportJson);
30
- const assistantMessages = report?.assistantMessages;
31
- if (Array.isArray(assistantMessages)) {
32
- const latest = assistantMessages.findLast((value) => typeof value === "string" && value.trim().length > 0);
33
- if (typeof latest === "string")
34
- return latest;
35
- }
36
- return undefined;
27
+ function shouldSuppressStatusNote(params) {
28
+ if (!params.activeRunType && params.sessionState !== "running")
29
+ return false;
30
+ const note = params.statusNote?.trim().toLowerCase();
31
+ if (!note)
32
+ return true;
33
+ return note === "codex turn was interrupted"
34
+ || note === "patchrelay received your mention. delegate the issue to patchrelay to start work.";
37
35
  }
38
36
  export function parseCiSnapshotSummary(snapshotJson) {
39
37
  if (!snapshotJson)
@@ -116,7 +114,7 @@ export class PatchRelayService {
116
114
  });
117
115
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
118
116
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
119
- this.queryService = new IssueQueryService(config, db, codex, this.orchestrator);
117
+ this.queryService = new IssueQueryService(db, codex, this.orchestrator);
120
118
  this.runtime = runtime;
121
119
  // Optional GitHub App token management for bot identity
122
120
  const ghAppCredentials = resolveGitHubAppCredentials();
@@ -132,6 +130,7 @@ export class PatchRelayService {
132
130
  });
133
131
  }
134
132
  async start() {
133
+ this.db.releaseExpiredIssueSessionLeases();
135
134
  const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
136
135
  for (const repair of repairedInstallations) {
137
136
  this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
@@ -166,14 +165,110 @@ export class PatchRelayService {
166
165
  const identity = this.githubAppTokenManager.botIdentity();
167
166
  if (identity) {
168
167
  this.orchestrator.botIdentity = identity;
168
+ this.githubWebhookHandler.setPatchRelayAuthorLogins([identity.name]);
169
169
  }
170
170
  }
171
171
  await this.runtime.start();
172
+ await this.recoverDelegatedIssueStateFromLinear();
173
+ void this.syncKnownAgentSessions().catch((error) => {
174
+ const msg = error instanceof Error ? error.message : String(error);
175
+ this.logger.warn({ error: msg }, "Background agent session sync failed");
176
+ });
172
177
  }
173
178
  async stop() {
174
179
  this.githubAppTokenManager?.stop();
175
180
  await this.runtime.stop();
176
181
  }
182
+ async syncKnownAgentSessions() {
183
+ for (const issue of this.db.listIssues()) {
184
+ if (issue.factoryState === "done") {
185
+ continue;
186
+ }
187
+ const syncedIssue = issue.agentSessionId
188
+ ? issue
189
+ : (() => {
190
+ const recoveredAgentSessionId = this.db.findLatestAgentSessionIdForIssue(issue.linearIssueId);
191
+ return recoveredAgentSessionId
192
+ ? this.db.upsertIssue({
193
+ projectId: issue.projectId,
194
+ linearIssueId: issue.linearIssueId,
195
+ agentSessionId: recoveredAgentSessionId,
196
+ })
197
+ : issue;
198
+ })();
199
+ if (!syncedIssue.agentSessionId) {
200
+ continue;
201
+ }
202
+ const activeRun = syncedIssue.activeRunId ? this.db.getRun(syncedIssue.activeRunId) : undefined;
203
+ await this.orchestrator.linearSync.syncSession(syncedIssue, activeRun ? { activeRunType: activeRun.runType } : undefined);
204
+ }
205
+ }
206
+ async recoverDelegatedIssueStateFromLinear() {
207
+ for (const issue of this.db.listIssuesWithAgentSessions()) {
208
+ if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
209
+ continue;
210
+ }
211
+ const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
212
+ if (!linear) {
213
+ continue;
214
+ }
215
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(issue.projectId);
216
+ if (!installation?.actorId) {
217
+ continue;
218
+ }
219
+ const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
220
+ if (!liveIssue) {
221
+ continue;
222
+ }
223
+ this.db.replaceIssueDependencies({
224
+ projectId: issue.projectId,
225
+ linearIssueId: issue.linearIssueId,
226
+ blockers: liveIssue.blockedBy.map((blocker) => ({
227
+ blockerLinearIssueId: blocker.id,
228
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
229
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
230
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
231
+ ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
232
+ })),
233
+ });
234
+ const delegated = liveIssue.delegateId === installation.actorId;
235
+ const unresolvedBlockers = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
236
+ const shouldRecoverAwaitingInput = delegated
237
+ && issue.factoryState === "awaiting_input"
238
+ && this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
239
+ const updated = this.db.upsertIssue({
240
+ projectId: issue.projectId,
241
+ linearIssueId: issue.linearIssueId,
242
+ ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
243
+ ...(liveIssue.title ? { title: liveIssue.title } : {}),
244
+ ...(liveIssue.description ? { description: liveIssue.description } : {}),
245
+ ...(liveIssue.url ? { url: liveIssue.url } : {}),
246
+ ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
247
+ ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
248
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
249
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
250
+ ...(shouldRecoverAwaitingInput ? { factoryState: "delegated" } : {}),
251
+ });
252
+ if (!shouldRecoverAwaitingInput) {
253
+ continue;
254
+ }
255
+ if (unresolvedBlockers === 0) {
256
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
257
+ projectId: issue.projectId,
258
+ linearIssueId: issue.linearIssueId,
259
+ eventType: "delegated",
260
+ dedupeKey: `delegated:${issue.linearIssueId}`,
261
+ });
262
+ if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
263
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
264
+ }
265
+ this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from stale awaiting_input state and re-queued implementation");
266
+ }
267
+ else {
268
+ this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from stale awaiting_input state");
269
+ }
270
+ }
271
+ }
177
272
  async createLinearOAuthStart(params) {
178
273
  return await this.oauthService.createStart(params);
179
274
  }
@@ -264,8 +359,8 @@ export class PatchRelayService {
264
359
  listTrackedIssues() {
265
360
  const rows = this.db.connection
266
361
  .prepare(`SELECT
267
- i.project_id, i.linear_issue_id, i.issue_key, i.title,
268
- i.current_linear_state, i.factory_state, i.updated_at,
362
+ s.project_id, s.linear_issue_id, s.issue_key, i.title,
363
+ i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
269
364
  i.pending_run_type,
270
365
  i.pr_number, i.pr_review_state, i.pr_check_status,
271
366
  i.last_github_ci_snapshot_json,
@@ -278,14 +373,21 @@ export class PatchRelayService {
278
373
  latest_run.status AS latest_run_status,
279
374
  latest_run.summary_json AS latest_run_summary_json,
280
375
  latest_run.report_json AS latest_run_report_json,
376
+ (
377
+ SELECT COUNT(*)
378
+ FROM issue_session_events e
379
+ WHERE e.project_id = s.project_id
380
+ AND e.linear_issue_id = s.linear_issue_id
381
+ AND e.processed_at IS NULL
382
+ ) AS pending_session_event_count,
281
383
  (
282
384
  SELECT COUNT(*)
283
385
  FROM issue_dependencies d
284
386
  LEFT JOIN issues blockers
285
387
  ON blockers.project_id = d.project_id
286
388
  AND blockers.linear_issue_id = d.blocker_linear_issue_id
287
- WHERE d.project_id = i.project_id
288
- AND d.linear_issue_id = i.linear_issue_id
389
+ WHERE d.project_id = s.project_id
390
+ AND d.linear_issue_id = s.linear_issue_id
289
391
  AND (
290
392
  COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
291
393
  AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
@@ -297,45 +399,99 @@ export class PatchRelayService {
297
399
  LEFT JOIN issues blockers
298
400
  ON blockers.project_id = d.project_id
299
401
  AND blockers.linear_issue_id = d.blocker_linear_issue_id
300
- WHERE d.project_id = i.project_id
301
- AND d.linear_issue_id = i.linear_issue_id
402
+ WHERE d.project_id = s.project_id
403
+ AND d.linear_issue_id = s.linear_issue_id
302
404
  AND (
303
405
  COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
304
406
  AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
305
407
  )
306
408
  ) AS blocked_by_keys_json
307
- FROM issues i
308
- LEFT JOIN runs active_run ON active_run.id = i.active_run_id
409
+ FROM issue_sessions s
410
+ LEFT JOIN issues i
411
+ ON i.project_id = s.project_id
412
+ AND i.linear_issue_id = s.linear_issue_id
413
+ LEFT JOIN runs active_run ON active_run.id = COALESCE(s.active_run_id, i.active_run_id)
309
414
  LEFT JOIN runs latest_run ON latest_run.id = (
310
415
  SELECT r.id FROM runs r
311
- WHERE r.project_id = i.project_id AND r.linear_issue_id = i.linear_issue_id
416
+ WHERE r.project_id = s.project_id AND r.linear_issue_id = s.linear_issue_id
312
417
  ORDER BY r.id DESC LIMIT 1
313
418
  )
314
- ORDER BY i.updated_at DESC, i.issue_key ASC`)
419
+ ORDER BY s.updated_at DESC, s.issue_key ASC`)
315
420
  .all();
316
421
  return rows.map((row) => {
317
422
  const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
318
423
  const prChecksSummary = parseCiSnapshotSummary(typeof row.last_github_ci_snapshot_json === "string" ? row.last_github_ci_snapshot_json : undefined);
319
- const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
320
424
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
321
425
  const blockedByCount = Number(row.blocked_by_count ?? 0);
322
- const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
426
+ const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
427
+ const hasPendingWake = hasPendingSessionEvents
428
+ || this.db.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
429
+ const readyForExecution = isIssueSessionReadyForExecution({
430
+ ...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
431
+ factoryState: String(row.factory_state ?? "delegated"),
432
+ ...(row.active_run_type !== null ? { activeRunId: 1 } : {}),
433
+ blockedByCount,
434
+ hasPendingWake,
435
+ hasLegacyPendingRun: row.pending_run_type !== null && row.pending_run_type !== undefined,
436
+ ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
437
+ ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
438
+ ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
439
+ ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
440
+ ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
441
+ });
323
442
  const failureSummary = summarizeGitHubFailureContext(failureContext);
324
- const derivedStatusNote = blockedByCount > 0
325
- ? `Blocked by ${blockedByKeys.join(", ")}`
326
- : failureSummary && (row.factory_state === "repairing_ci"
327
- || row.factory_state === "repairing_queue"
328
- || row.factory_state === "failed")
329
- ? failureSummary
330
- : statusNote;
331
- const statusNoteWithBlockers = blockedByCount > 0
332
- ? `Blocked by ${blockedByKeys.join(", ")}`
333
- : derivedStatusNote;
443
+ const sessionWaitingReason = typeof row.waiting_reason === "string" && row.waiting_reason.trim().length > 0
444
+ ? row.waiting_reason
445
+ : undefined;
446
+ const sessionSummary = typeof row.summary_text === "string" && row.summary_text.trim().length > 0
447
+ ? row.summary_text
448
+ : undefined;
449
+ const waitingReason = sessionWaitingReason ?? derivePatchRelayWaitingReason({
450
+ ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
451
+ blockedByKeys,
452
+ factoryState: String(row.factory_state ?? "delegated"),
453
+ ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
454
+ ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
455
+ ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
456
+ ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
457
+ ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
458
+ });
459
+ const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
460
+ ? {
461
+ id: 0,
462
+ issueId: 0,
463
+ projectId: String(row.project_id),
464
+ linearIssueId: String(row.linear_issue_id),
465
+ runType: String(row.latest_run_type),
466
+ status: String(row.latest_run_status),
467
+ ...(typeof row.latest_run_summary_json === "string" ? { summaryJson: row.latest_run_summary_json } : {}),
468
+ ...(typeof row.latest_run_report_json === "string" ? { reportJson: row.latest_run_report_json } : {}),
469
+ startedAt: String(row.updated_at),
470
+ }
471
+ : undefined;
472
+ const latestEvent = this.db.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
473
+ const statusNoteCandidate = deriveIssueStatusNote({
474
+ issue: { factoryState: String(row.factory_state ?? "delegated") },
475
+ sessionSummary,
476
+ latestRun: latestRun,
477
+ latestEvent,
478
+ failureSummary,
479
+ blockedByKeys,
480
+ waitingReason,
481
+ }) ?? waitingReason;
482
+ const statusNoteForReturn = shouldSuppressStatusNote({
483
+ activeRunType: row.active_run_type,
484
+ sessionState: row.session_state,
485
+ statusNote: statusNoteCandidate,
486
+ })
487
+ ? undefined
488
+ : statusNoteCandidate;
334
489
  return {
335
490
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
336
491
  ...(row.title !== null ? { title: String(row.title) } : {}),
337
- ...(statusNoteWithBlockers ? { statusNote: statusNoteWithBlockers } : {}),
492
+ ...(statusNoteForReturn ? { statusNote: statusNoteForReturn } : {}),
338
493
  projectId: String(row.project_id),
494
+ ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
339
495
  factoryState: String(row.factory_state ?? "delegated"),
340
496
  blockedByCount,
341
497
  blockedByKeys,
@@ -354,45 +510,11 @@ export class PatchRelayService {
354
510
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
355
511
  ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
356
512
  ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
513
+ ...(waitingReason ? { waitingReason } : {}),
357
514
  updatedAt: String(row.updated_at),
358
515
  };
359
516
  });
360
517
  }
361
- subscribeCodexNotifications(listener) {
362
- let trackedThreadId;
363
- const handler = (notification) => {
364
- let threadId = typeof notification.params.threadId === "string"
365
- ? notification.params.threadId
366
- : typeof notification.params.thread === "object" && notification.params.thread !== null && "id" in notification.params.thread
367
- ? String(notification.params.thread.id)
368
- : undefined;
369
- // Item-level notifications lack threadId — use the tracked one from turn/started
370
- if (!threadId)
371
- threadId = trackedThreadId;
372
- if (notification.method === "turn/started" && threadId)
373
- trackedThreadId = threadId;
374
- if (notification.method === "turn/completed")
375
- trackedThreadId = undefined;
376
- let issueKey;
377
- let runId;
378
- if (threadId) {
379
- const run = this.db.getRunByThreadId(threadId);
380
- if (run) {
381
- runId = run.id;
382
- const issue = this.db.getIssue(run.projectId, run.linearIssueId);
383
- issueKey = issue?.issueKey ?? undefined;
384
- }
385
- }
386
- listener({
387
- method: notification.method,
388
- params: notification.params,
389
- ...(issueKey ? { issueKey } : {}),
390
- ...(runId !== undefined ? { runId } : {}),
391
- });
392
- };
393
- this.codex.on("notification", handler);
394
- return () => { this.codex.off("notification", handler); };
395
- }
396
518
  async promptIssue(issueKey, text, source = "watch") {
397
519
  const issue = this.db.getIssueByKey(issueKey);
398
520
  if (!issue)
@@ -410,14 +532,13 @@ export class PatchRelayService {
410
532
  });
411
533
  // If no active run, queue as pending context for the next run
412
534
  if (!issue.activeRunId) {
413
- const existing = issue.pendingRunContextJson
414
- ? JSON.parse(issue.pendingRunContextJson)
415
- : {};
416
- this.db.upsertIssue({
535
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
417
536
  projectId: issue.projectId,
418
537
  linearIssueId: issue.linearIssueId,
419
- pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
538
+ eventType: "operator_prompt",
539
+ eventJson: JSON.stringify({ text, source }),
420
540
  });
541
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
421
542
  return { delivered: false, queued: true };
422
543
  }
423
544
  const run = this.db.getRun(issue.activeRunId);
@@ -436,14 +557,13 @@ export class PatchRelayService {
436
557
  // Turn may have completed between check and steer — queue for next run
437
558
  const msg = error instanceof Error ? error.message : String(error);
438
559
  this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
439
- const existing = issue.pendingRunContextJson
440
- ? JSON.parse(issue.pendingRunContextJson)
441
- : {};
442
- this.db.upsertIssue({
560
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
443
561
  projectId: issue.projectId,
444
562
  linearIssueId: issue.linearIssueId,
445
- pendingRunContextJson: JSON.stringify({ ...existing, operatorPrompt: text }),
563
+ eventType: "operator_prompt",
564
+ eventJson: JSON.stringify({ text, source }),
446
565
  });
566
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
447
567
  return { delivered: false, queued: true };
448
568
  }
449
569
  }
@@ -466,7 +586,14 @@ export class PatchRelayService {
466
586
  // Turn may already be done
467
587
  }
468
588
  }
469
- this.db.upsertIssue({
589
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
590
+ projectId: issue.projectId,
591
+ linearIssueId: issue.linearIssueId,
592
+ eventType: "stop_requested",
593
+ dedupeKey: `operator_stop:${issue.linearIssueId}`,
594
+ });
595
+ this.db.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
596
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
470
597
  projectId: issue.projectId,
471
598
  linearIssueId: issue.linearIssueId,
472
599
  factoryState: "awaiting_input",
@@ -488,13 +615,21 @@ export class PatchRelayService {
488
615
  if (issue.activeRunId)
489
616
  return { error: "Issue already has an active run" };
490
617
  if (issue.prState === "merged") {
491
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, factoryState: "done" });
618
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
619
+ projectId: issue.projectId,
620
+ linearIssueId: issue.linearIssueId,
621
+ factoryState: "done",
622
+ });
492
623
  return { issueKey, runType: "none" };
493
624
  }
494
625
  // Infer run type from current state instead of always resetting to implementation
495
626
  let runType = "implementation";
496
627
  let factoryState = "delegated";
497
- if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
628
+ if (issue.prNumber && issue.lastGitHubFailureSource === "queue_eviction") {
629
+ runType = "queue_repair";
630
+ factoryState = "repairing_queue";
631
+ }
632
+ else if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
498
633
  runType = "ci_repair";
499
634
  factoryState = "repairing_ci";
500
635
  }
@@ -507,10 +642,10 @@ export class PatchRelayService {
507
642
  runType = "implementation";
508
643
  factoryState = "implementing";
509
644
  }
510
- this.db.upsertIssue({
645
+ this.appendOperatorRetryEvent(issue, runType);
646
+ this.db.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
511
647
  projectId: issue.projectId,
512
648
  linearIssueId: issue.linearIssueId,
513
- pendingRunType: runType,
514
649
  factoryState: factoryState,
515
650
  });
516
651
  this.feed.publish({
@@ -522,14 +657,65 @@ export class PatchRelayService {
522
657
  status: "retry",
523
658
  summary: `Retry queued: ${runType}`,
524
659
  });
525
- this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
660
+ if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
661
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
662
+ }
526
663
  return { issueKey, runType };
527
664
  }
528
- listOperatorFeed(options) {
529
- return this.feed.list(options);
530
- }
531
- subscribeOperatorFeed(listener) {
532
- return this.feed.subscribe(listener);
665
+ appendOperatorRetryEvent(issue, runType) {
666
+ if (runType === "queue_repair") {
667
+ const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
668
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
669
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
670
+ projectId: issue.projectId,
671
+ linearIssueId: issue.linearIssueId,
672
+ eventType: "merge_steward_incident",
673
+ eventJson: JSON.stringify({
674
+ ...(queueIncident ?? {}),
675
+ ...(failureContext ?? {}),
676
+ source: "operator_retry",
677
+ }),
678
+ dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
679
+ });
680
+ return;
681
+ }
682
+ if (runType === "ci_repair") {
683
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
684
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
685
+ projectId: issue.projectId,
686
+ linearIssueId: issue.linearIssueId,
687
+ eventType: "settled_red_ci",
688
+ eventJson: JSON.stringify({
689
+ ...(failureContext ?? {}),
690
+ source: "operator_retry",
691
+ }),
692
+ dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
693
+ });
694
+ return;
695
+ }
696
+ if (runType === "review_fix") {
697
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
698
+ projectId: issue.projectId,
699
+ linearIssueId: issue.linearIssueId,
700
+ eventType: "review_changes_requested",
701
+ eventJson: JSON.stringify({
702
+ reviewBody: "Operator requested retry of review-fix work.",
703
+ source: "operator_retry",
704
+ }),
705
+ dedupeKey: `operator_retry:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
706
+ });
707
+ return;
708
+ }
709
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
710
+ projectId: issue.projectId,
711
+ linearIssueId: issue.linearIssueId,
712
+ eventType: "delegated",
713
+ eventJson: JSON.stringify({
714
+ promptContext: "Operator requested retry of PatchRelay work.",
715
+ source: "operator_retry",
716
+ }),
717
+ dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
718
+ });
533
719
  }
534
720
  async acceptWebhook(params) {
535
721
  const result = await acceptIncomingWebhook({
@@ -577,15 +763,6 @@ export class PatchRelayService {
577
763
  async getIssueOverview(issueKey) {
578
764
  return await this.queryService.getIssueOverview(issueKey);
579
765
  }
580
- async getIssueReport(issueKey) {
581
- return await this.queryService.getIssueReport(issueKey);
582
- }
583
- async getIssueTimeline(issueKey) {
584
- return await this.queryService.getIssueTimeline(issueKey);
585
- }
586
- async getRunEvents(issueKey, runId) {
587
- return await this.queryService.getRunEvents(issueKey, runId);
588
- }
589
766
  async getActiveRunStatus(issueKey) {
590
767
  return await this.orchestrator.getActiveRunStatus(issueKey);
591
768
  }
@@ -0,0 +1,56 @@
1
+ import { extractLatestAssistantSummary } from "./issue-session-events.js";
2
+ function clean(value) {
3
+ const trimmed = value?.trim();
4
+ return trimmed ? trimmed : undefined;
5
+ }
6
+ function eventStatusNote(event) {
7
+ if (!event)
8
+ return undefined;
9
+ switch (event.eventType) {
10
+ case "stop_requested":
11
+ return "Operator stopped the run. Use retry or delegate again to resume.";
12
+ case "undelegated":
13
+ return "Issue was un-delegated from PatchRelay. Delegate it again to resume.";
14
+ case "issue_removed":
15
+ return "Issue was removed from Linear.";
16
+ case "pr_closed":
17
+ return "Pull request was closed without merging.";
18
+ case "pr_merged":
19
+ return "Pull request merged successfully.";
20
+ default:
21
+ return undefined;
22
+ }
23
+ }
24
+ export function deriveIssueStatusNote(params) {
25
+ const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
26
+ if (blockedByKeys.length > 0) {
27
+ return `Blocked by ${blockedByKeys.join(", ")}`;
28
+ }
29
+ const sessionSummary = clean(params.sessionSummary);
30
+ const latestRunNote = clean(extractLatestAssistantSummary(params.latestRun));
31
+ const latestEventNote = clean(eventStatusNote(params.latestEvent));
32
+ const failureSummary = clean(params.failureSummary);
33
+ const waitingReason = clean(params.waitingReason);
34
+ let note;
35
+ switch (params.issue.factoryState) {
36
+ case "awaiting_input":
37
+ note = latestRunNote ?? latestEventNote ?? sessionSummary;
38
+ break;
39
+ case "failed":
40
+ case "escalated":
41
+ note = latestRunNote ?? latestEventNote ?? failureSummary ?? sessionSummary;
42
+ break;
43
+ case "repairing_ci":
44
+ case "repairing_queue":
45
+ note = failureSummary ?? sessionSummary ?? latestRunNote;
46
+ break;
47
+ default:
48
+ note = sessionSummary ?? latestRunNote ?? failureSummary;
49
+ break;
50
+ }
51
+ if (!note)
52
+ return undefined;
53
+ if (waitingReason && note === waitingReason)
54
+ return undefined;
55
+ return note;
56
+ }