patchrelay 0.36.18 → 0.36.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.js CHANGED
@@ -1,87 +1,17 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
2
- import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./github-failure-context.js";
3
- import { isIssueSessionReadyForExecution } from "./issue-session.js";
4
2
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
5
3
  import { IssueQueryService } from "./issue-query-service.js";
6
4
  import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
7
5
  import { LinearOAuthService } from "./linear-oauth-service.js";
8
6
  import { RunOrchestrator } from "./run-orchestrator.js";
9
7
  import { OperatorEventFeed } from "./operator-feed.js";
10
- import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
11
8
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
12
9
  import { ServiceRuntime } from "./service-runtime.js";
10
+ import { ServiceIssueActions } from "./service-issue-actions.js";
11
+ import { ServiceStartupRecovery } from "./service-startup-recovery.js";
13
12
  import { WebhookHandler } from "./webhook-handler.js";
14
13
  import { acceptIncomingWebhook } from "./service-webhooks.js";
15
- import { deriveIssueStatusNote } from "./status-note.js";
16
- function parseObjectJson(value) {
17
- if (!value)
18
- return undefined;
19
- try {
20
- const parsed = JSON.parse(value);
21
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
22
- }
23
- catch {
24
- return undefined;
25
- }
26
- }
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.startsWith("zombie: never started")
35
- || note === "stale thread after restart"
36
- || note === "patchrelay received your mention. delegate the issue to patchrelay to start work.";
37
- }
38
- export function parseCiSnapshotSummary(snapshotJson) {
39
- if (!snapshotJson)
40
- return undefined;
41
- try {
42
- const snapshot = JSON.parse(snapshotJson);
43
- const rawChecks = Array.isArray(snapshot.checks) ? snapshot.checks : [];
44
- const checks = collapseEffectiveChecks(rawChecks);
45
- if (checks.length === 0)
46
- return undefined;
47
- let passed = 0;
48
- let failed = 0;
49
- let pending = 0;
50
- const failedNames = [];
51
- for (const check of checks) {
52
- if (check.status === "success")
53
- passed++;
54
- else if (check.status === "failure") {
55
- failed++;
56
- failedNames.push(check.name);
57
- }
58
- else
59
- pending++;
60
- }
61
- return {
62
- total: checks.length,
63
- completed: passed + failed,
64
- passed,
65
- failed,
66
- pending,
67
- overall: snapshot.gateCheckStatus,
68
- ...(failedNames.length > 0 ? { failedNames } : {}),
69
- };
70
- }
71
- catch {
72
- return undefined;
73
- }
74
- }
75
- function collapseEffectiveChecks(checks) {
76
- const effective = new Map();
77
- for (const check of checks) {
78
- const name = typeof check?.name === "string" ? check.name.trim() : "";
79
- if (!name || effective.has(name))
80
- continue;
81
- effective.set(name, check);
82
- }
83
- return [...effective.values()];
84
- }
14
+ import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
85
15
  export class PatchRelayService {
86
16
  config;
87
17
  db;
@@ -96,6 +26,9 @@ export class PatchRelayService {
96
26
  queryService;
97
27
  runtime;
98
28
  feed;
29
+ issueActions;
30
+ startupRecovery;
31
+ trackedIssueListQuery;
99
32
  constructor(config, db, codex, linearProvider, logger) {
100
33
  this.config = config;
101
34
  this.db = db;
@@ -118,6 +51,9 @@ export class PatchRelayService {
118
51
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
119
52
  this.queryService = new IssueQueryService(db, codex, this.orchestrator);
120
53
  this.runtime = runtime;
54
+ this.issueActions = new ServiceIssueActions(db, codex, runtime, this.feed, logger);
55
+ this.startupRecovery = new ServiceStartupRecovery(db, this.linearProvider, this.orchestrator.linearSync, (projectId, issueId) => runtime.enqueueIssue(projectId, issueId), logger);
56
+ this.trackedIssueListQuery = new TrackedIssueListQuery(db);
121
57
  // Optional GitHub App token management for bot identity
122
58
  const ghAppCredentials = resolveGitHubAppCredentials();
123
59
  if (ghAppCredentials) {
@@ -171,8 +107,8 @@ export class PatchRelayService {
171
107
  }
172
108
  }
173
109
  await this.runtime.start();
174
- await this.recoverDelegatedIssueStateFromLinear();
175
- void this.syncKnownAgentSessions().catch((error) => {
110
+ await this.startupRecovery.recoverDelegatedIssueStateFromLinear();
111
+ void this.startupRecovery.syncKnownAgentSessions().catch((error) => {
176
112
  const msg = error instanceof Error ? error.message : String(error);
177
113
  this.logger.warn({ error: msg }, "Background agent session sync failed");
178
114
  });
@@ -181,96 +117,6 @@ export class PatchRelayService {
181
117
  this.githubAppTokenManager?.stop();
182
118
  await this.runtime.stop();
183
119
  }
184
- async syncKnownAgentSessions() {
185
- for (const issue of this.db.issues.listIssues()) {
186
- if (issue.factoryState === "done") {
187
- continue;
188
- }
189
- const syncedIssue = issue.agentSessionId
190
- ? issue
191
- : (() => {
192
- const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
193
- return recoveredAgentSessionId
194
- ? this.db.issues.upsertIssue({
195
- projectId: issue.projectId,
196
- linearIssueId: issue.linearIssueId,
197
- agentSessionId: recoveredAgentSessionId,
198
- })
199
- : issue;
200
- })();
201
- if (!syncedIssue.agentSessionId) {
202
- continue;
203
- }
204
- const activeRun = syncedIssue.activeRunId ? this.db.runs.getRunById(syncedIssue.activeRunId) : undefined;
205
- await this.orchestrator.linearSync.syncSession(syncedIssue, activeRun ? { activeRunType: activeRun.runType } : undefined);
206
- }
207
- }
208
- async recoverDelegatedIssueStateFromLinear() {
209
- for (const issue of this.db.issues.listIssuesWithAgentSessions()) {
210
- if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
211
- continue;
212
- }
213
- const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
214
- if (!linear) {
215
- continue;
216
- }
217
- const installation = this.db.linearInstallations.getLinearInstallationForProject(issue.projectId);
218
- if (!installation?.actorId) {
219
- continue;
220
- }
221
- const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
222
- if (!liveIssue) {
223
- continue;
224
- }
225
- this.db.issues.replaceIssueDependencies({
226
- projectId: issue.projectId,
227
- linearIssueId: issue.linearIssueId,
228
- blockers: liveIssue.blockedBy.map((blocker) => ({
229
- blockerLinearIssueId: blocker.id,
230
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
231
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
232
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
233
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
234
- })),
235
- });
236
- const delegated = liveIssue.delegateId === installation.actorId;
237
- const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
238
- const shouldRecoverAwaitingInput = delegated
239
- && issue.factoryState === "awaiting_input"
240
- && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
241
- const updated = this.db.issues.upsertIssue({
242
- projectId: issue.projectId,
243
- linearIssueId: issue.linearIssueId,
244
- ...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
245
- ...(liveIssue.title ? { title: liveIssue.title } : {}),
246
- ...(liveIssue.description ? { description: liveIssue.description } : {}),
247
- ...(liveIssue.url ? { url: liveIssue.url } : {}),
248
- ...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
249
- ...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
250
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
251
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
252
- ...(shouldRecoverAwaitingInput ? { factoryState: "delegated" } : {}),
253
- });
254
- if (!shouldRecoverAwaitingInput) {
255
- continue;
256
- }
257
- if (unresolvedBlockers === 0) {
258
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
259
- projectId: issue.projectId,
260
- linearIssueId: issue.linearIssueId,
261
- eventType: "delegated",
262
- dedupeKey: `delegated:${issue.linearIssueId}`,
263
- });
264
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
265
- this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
266
- }
267
- this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from stale awaiting_input state and re-queued implementation");
268
- }
269
- else {
270
- this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from stale awaiting_input state");
271
- }
272
- }
273
- }
274
120
  async createLinearOAuthStart(params) {
275
121
  return await this.oauthService.createStart(params);
276
122
  }
@@ -359,392 +205,16 @@ export class PatchRelayService {
359
205
  return this.runtime.getReadiness();
360
206
  }
361
207
  listTrackedIssues() {
362
- const rows = this.db.connection
363
- .prepare(`SELECT
364
- s.project_id, s.linear_issue_id, s.issue_key, i.title,
365
- i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
366
- i.pending_run_type,
367
- i.pr_number, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
368
- i.last_github_ci_snapshot_json,
369
- i.last_github_failure_source,
370
- i.last_github_failure_head_sha,
371
- i.last_github_failure_check_name,
372
- i.last_github_failure_context_json,
373
- active_run.run_type AS active_run_type,
374
- active_run.completion_check_thread_id AS active_completion_check_thread_id,
375
- active_run.completion_check_outcome AS active_completion_check_outcome,
376
- latest_run.run_type AS latest_run_type,
377
- latest_run.status AS latest_run_status,
378
- latest_run.summary_json AS latest_run_summary_json,
379
- latest_run.report_json AS latest_run_report_json,
380
- latest_run.completion_check_thread_id AS latest_run_completion_check_thread_id,
381
- latest_run.completion_check_outcome AS latest_run_completion_check_outcome,
382
- latest_run.completion_check_summary AS latest_run_completion_check_summary,
383
- latest_run.completion_check_question AS latest_run_completion_check_question,
384
- latest_run.completion_check_why AS latest_run_completion_check_why,
385
- latest_run.completion_check_recommended_reply AS latest_run_completion_check_recommended_reply,
386
- (
387
- SELECT COUNT(*)
388
- FROM issue_session_events e
389
- WHERE e.project_id = s.project_id
390
- AND e.linear_issue_id = s.linear_issue_id
391
- AND e.processed_at IS NULL
392
- ) AS pending_session_event_count,
393
- (
394
- SELECT COUNT(*)
395
- FROM issue_dependencies d
396
- LEFT JOIN issues blockers
397
- ON blockers.project_id = d.project_id
398
- AND blockers.linear_issue_id = d.blocker_linear_issue_id
399
- WHERE d.project_id = s.project_id
400
- AND d.linear_issue_id = s.linear_issue_id
401
- AND (
402
- COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
403
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
404
- )
405
- ) AS blocked_by_count,
406
- (
407
- SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
408
- FROM issue_dependencies d
409
- LEFT JOIN issues blockers
410
- ON blockers.project_id = d.project_id
411
- AND blockers.linear_issue_id = d.blocker_linear_issue_id
412
- WHERE d.project_id = s.project_id
413
- AND d.linear_issue_id = s.linear_issue_id
414
- AND (
415
- COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
416
- AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
417
- )
418
- ) AS blocked_by_keys_json
419
- FROM issue_sessions s
420
- LEFT JOIN issues i
421
- ON i.project_id = s.project_id
422
- AND i.linear_issue_id = s.linear_issue_id
423
- LEFT JOIN runs active_run ON active_run.id = COALESCE(s.active_run_id, i.active_run_id)
424
- LEFT JOIN runs latest_run ON latest_run.id = (
425
- SELECT r.id FROM runs r
426
- WHERE r.project_id = s.project_id AND r.linear_issue_id = s.linear_issue_id
427
- ORDER BY r.id DESC LIMIT 1
428
- )
429
- ORDER BY s.updated_at DESC, s.issue_key ASC`)
430
- .all();
431
- return rows.map((row) => {
432
- const failureContext = parseGitHubFailureContext(typeof row.last_github_failure_context_json === "string" ? row.last_github_failure_context_json : undefined);
433
- const prChecksSummary = parseCiSnapshotSummary(typeof row.last_github_ci_snapshot_json === "string" ? row.last_github_ci_snapshot_json : undefined);
434
- const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
435
- const blockedByCount = Number(row.blocked_by_count ?? 0);
436
- const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
437
- const hasPendingWake = hasPendingSessionEvents
438
- || this.db.issueSessions.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
439
- const readyForExecution = isIssueSessionReadyForExecution({
440
- ...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
441
- factoryState: String(row.factory_state ?? "delegated"),
442
- ...(row.active_run_type !== null ? { activeRunId: 1 } : {}),
443
- blockedByCount,
444
- hasPendingWake,
445
- hasLegacyPendingRun: row.pending_run_type !== null && row.pending_run_type !== undefined,
446
- ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
447
- ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
448
- ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
449
- ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
450
- ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
451
- });
452
- const failureSummary = summarizeGitHubFailureContext(failureContext);
453
- const sessionWaitingReason = typeof row.waiting_reason === "string" && row.waiting_reason.trim().length > 0
454
- ? row.waiting_reason
455
- : undefined;
456
- const sessionSummary = typeof row.summary_text === "string" && row.summary_text.trim().length > 0
457
- ? row.summary_text
458
- : undefined;
459
- const waitingReason = sessionWaitingReason ?? derivePatchRelayWaitingReason({
460
- ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
461
- blockedByKeys,
462
- factoryState: String(row.factory_state ?? "delegated"),
463
- ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
464
- ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
465
- ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
466
- ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
467
- ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
468
- ...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
469
- ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
470
- });
471
- const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
472
- ? {
473
- id: 0,
474
- issueId: 0,
475
- projectId: String(row.project_id),
476
- linearIssueId: String(row.linear_issue_id),
477
- runType: String(row.latest_run_type),
478
- status: String(row.latest_run_status),
479
- ...(typeof row.latest_run_summary_json === "string" ? { summaryJson: row.latest_run_summary_json } : {}),
480
- ...(typeof row.latest_run_report_json === "string" ? { reportJson: row.latest_run_report_json } : {}),
481
- ...(typeof row.latest_run_completion_check_thread_id === "string" ? { completionCheckThreadId: row.latest_run_completion_check_thread_id } : {}),
482
- ...(typeof row.latest_run_completion_check_outcome === "string" ? { completionCheckOutcome: row.latest_run_completion_check_outcome } : {}),
483
- ...(typeof row.latest_run_completion_check_summary === "string" ? { completionCheckSummary: row.latest_run_completion_check_summary } : {}),
484
- ...(typeof row.latest_run_completion_check_question === "string" ? { completionCheckQuestion: row.latest_run_completion_check_question } : {}),
485
- ...(typeof row.latest_run_completion_check_why === "string" ? { completionCheckWhy: row.latest_run_completion_check_why } : {}),
486
- ...(typeof row.latest_run_completion_check_recommended_reply === "string" ? { completionCheckRecommendedReply: row.latest_run_completion_check_recommended_reply } : {}),
487
- startedAt: String(row.updated_at),
488
- }
489
- : undefined;
490
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(String(row.project_id), String(row.linear_issue_id), { limit: 1 }).at(-1);
491
- const statusNoteCandidate = deriveIssueStatusNote({
492
- issue: { factoryState: String(row.factory_state ?? "delegated") },
493
- sessionSummary,
494
- latestRun: latestRun,
495
- latestEvent,
496
- failureSummary,
497
- blockedByKeys,
498
- waitingReason,
499
- }) ?? waitingReason;
500
- const statusNoteForReturn = shouldSuppressStatusNote({
501
- activeRunType: row.active_run_type,
502
- sessionState: row.session_state,
503
- statusNote: statusNoteCandidate,
504
- })
505
- ? undefined
506
- : statusNoteCandidate;
507
- const completionCheckActive = typeof row.active_completion_check_thread_id === "string"
508
- && row.active_completion_check_thread_id.length > 0
509
- && row.active_completion_check_outcome === null
510
- && row.active_run_type !== null;
511
- return {
512
- ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
513
- ...(row.title !== null ? { title: String(row.title) } : {}),
514
- ...(statusNoteForReturn ? { statusNote: statusNoteForReturn } : {}),
515
- projectId: String(row.project_id),
516
- ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
517
- factoryState: String(row.factory_state ?? "delegated"),
518
- blockedByCount,
519
- blockedByKeys,
520
- readyForExecution,
521
- ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
522
- ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
523
- ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
524
- ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
525
- ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
526
- ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
527
- ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
528
- ...(row.pr_check_status !== null ? { prCheckStatus: String(row.pr_check_status) } : {}),
529
- ...(prChecksSummary ? { prChecksSummary } : {}),
530
- ...(row.last_github_failure_source !== null ? { latestFailureSource: String(row.last_github_failure_source) } : {}),
531
- ...(row.last_github_failure_head_sha !== null ? { latestFailureHeadSha: String(row.last_github_failure_head_sha) } : {}),
532
- ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
533
- ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
534
- ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
535
- ...(waitingReason ? { waitingReason } : {}),
536
- ...(completionCheckActive ? { completionCheckActive } : {}),
537
- updatedAt: String(row.updated_at),
538
- };
539
- });
208
+ return this.trackedIssueListQuery.listTrackedIssues();
540
209
  }
541
210
  async promptIssue(issueKey, text, source = "watch") {
542
- const issue = this.db.issues.getIssueByKey(issueKey);
543
- if (!issue)
544
- return undefined;
545
- // Publish to operator feed so all clients see the prompt
546
- this.feed.publish({
547
- level: "info",
548
- kind: "comment",
549
- issueKey: issue.issueKey,
550
- projectId: issue.projectId,
551
- stage: issue.factoryState,
552
- status: "operator_prompt",
553
- summary: `Operator prompt (${source})`,
554
- detail: text.slice(0, 200),
555
- });
556
- // If no active run, queue as pending context for the next run
557
- if (!issue.activeRunId) {
558
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
559
- projectId: issue.projectId,
560
- linearIssueId: issue.linearIssueId,
561
- eventType: "operator_prompt",
562
- eventJson: JSON.stringify({ text, source }),
563
- });
564
- this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
565
- return { delivered: false, queued: true };
566
- }
567
- const run = this.db.runs.getRunById(issue.activeRunId);
568
- if (!run?.threadId || !run.turnId) {
569
- return { error: "Active run has no thread or turn yet" };
570
- }
571
- try {
572
- await this.codex.steerTurn({
573
- threadId: run.threadId,
574
- turnId: run.turnId,
575
- input: `Operator prompt (${source}):\n\n${text}`,
576
- });
577
- return { delivered: true };
578
- }
579
- catch (error) {
580
- // Turn may have completed between check and steer — queue for next run
581
- const msg = error instanceof Error ? error.message : String(error);
582
- this.logger.warn({ issueKey, error: msg }, "steerTurn failed, queuing prompt for next run");
583
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
584
- projectId: issue.projectId,
585
- linearIssueId: issue.linearIssueId,
586
- eventType: "operator_prompt",
587
- eventJson: JSON.stringify({ text, source }),
588
- });
589
- this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
590
- return { delivered: false, queued: true };
591
- }
211
+ return await this.issueActions.promptIssue(issueKey, text, source);
592
212
  }
593
213
  async stopIssue(issueKey) {
594
- const issue = this.db.issues.getIssueByKey(issueKey);
595
- if (!issue)
596
- return undefined;
597
- if (!issue.activeRunId)
598
- return { error: "No active run to stop" };
599
- const run = this.db.runs.getRunById(issue.activeRunId);
600
- if (run?.threadId && run.turnId) {
601
- try {
602
- await this.codex.steerTurn({
603
- threadId: run.threadId,
604
- turnId: run.turnId,
605
- input: "STOP: The operator has requested this run to halt immediately. Finish your current action, commit any partial progress, and stop.",
606
- });
607
- }
608
- catch {
609
- // Turn may already be done
610
- }
611
- }
612
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
613
- projectId: issue.projectId,
614
- linearIssueId: issue.linearIssueId,
615
- eventType: "stop_requested",
616
- dedupeKey: `operator_stop:${issue.linearIssueId}`,
617
- });
618
- this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
619
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
620
- projectId: issue.projectId,
621
- linearIssueId: issue.linearIssueId,
622
- factoryState: "awaiting_input",
623
- });
624
- this.feed.publish({
625
- level: "warn",
626
- kind: "workflow",
627
- issueKey: issue.issueKey,
628
- projectId: issue.projectId,
629
- status: "stopped",
630
- summary: "Operator stopped the run",
631
- });
632
- return { stopped: true };
214
+ return await this.issueActions.stopIssue(issueKey);
633
215
  }
634
216
  retryIssue(issueKey) {
635
- const issue = this.db.issues.getIssueByKey(issueKey);
636
- if (!issue)
637
- return undefined;
638
- if (issue.activeRunId)
639
- return { error: "Issue already has an active run" };
640
- const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
641
- if (issue.prState === "merged") {
642
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
643
- projectId: issue.projectId,
644
- linearIssueId: issue.linearIssueId,
645
- factoryState: "done",
646
- });
647
- return { issueKey, runType: "none" };
648
- }
649
- // Infer run type from current state instead of always resetting to implementation
650
- let runType = "implementation";
651
- let factoryState = "delegated";
652
- if (issue.prNumber && issue.lastGitHubFailureSource === "queue_eviction") {
653
- runType = "queue_repair";
654
- factoryState = "repairing_queue";
655
- }
656
- else if (issue.prNumber && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
657
- runType = "ci_repair";
658
- factoryState = "repairing_ci";
659
- }
660
- else if (issue.prNumber && issue.prReviewState === "changes_requested") {
661
- runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
662
- ? "branch_upkeep"
663
- : "review_fix";
664
- factoryState = "changes_requested";
665
- }
666
- else if (issue.prNumber) {
667
- // PR exists but no specific failure — re-run implementation
668
- runType = "implementation";
669
- factoryState = "implementing";
670
- }
671
- this.appendOperatorRetryEvent(issue, runType);
672
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
673
- projectId: issue.projectId,
674
- linearIssueId: issue.linearIssueId,
675
- factoryState: factoryState,
676
- });
677
- this.feed.publish({
678
- level: "info",
679
- kind: "stage",
680
- issueKey: issue.issueKey,
681
- projectId: issue.projectId,
682
- stage: factoryState,
683
- status: "retry",
684
- summary: `Retry queued: ${runType}`,
685
- });
686
- if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
687
- this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
688
- }
689
- return { issueKey, runType };
690
- }
691
- appendOperatorRetryEvent(issue, runType) {
692
- if (runType === "queue_repair") {
693
- const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
694
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
695
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
696
- projectId: issue.projectId,
697
- linearIssueId: issue.linearIssueId,
698
- eventType: "merge_steward_incident",
699
- eventJson: JSON.stringify({
700
- ...(queueIncident ?? {}),
701
- ...(failureContext ?? {}),
702
- source: "operator_retry",
703
- }),
704
- dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
705
- });
706
- return;
707
- }
708
- if (runType === "ci_repair") {
709
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
710
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
711
- projectId: issue.projectId,
712
- linearIssueId: issue.linearIssueId,
713
- eventType: "settled_red_ci",
714
- eventJson: JSON.stringify({
715
- ...(failureContext ?? {}),
716
- source: "operator_retry",
717
- }),
718
- dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
719
- });
720
- return;
721
- }
722
- if (runType === "review_fix" || runType === "branch_upkeep") {
723
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
724
- projectId: issue.projectId,
725
- linearIssueId: issue.linearIssueId,
726
- eventType: "review_changes_requested",
727
- eventJson: JSON.stringify({
728
- reviewBody: runType === "branch_upkeep"
729
- ? "Operator requested retry of branch upkeep after requested changes."
730
- : "Operator requested retry of review-fix work.",
731
- ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
732
- source: "operator_retry",
733
- }),
734
- dedupeKey: `operator_retry:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
735
- });
736
- return;
737
- }
738
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
739
- projectId: issue.projectId,
740
- linearIssueId: issue.linearIssueId,
741
- eventType: "delegated",
742
- eventJson: JSON.stringify({
743
- promptContext: "Operator requested retry of PatchRelay work.",
744
- source: "operator_retry",
745
- }),
746
- dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
747
- });
217
+ return this.issueActions.retryIssue(issueKey);
748
218
  }
749
219
  async acceptWebhook(params) {
750
220
  const result = await acceptIncomingWebhook({
@@ -838,17 +308,6 @@ function toLinearClientProvider(linear) {
838
308
  },
839
309
  };
840
310
  }
841
- function parseStringArray(value) {
842
- if (!value)
843
- return [];
844
- try {
845
- const parsed = JSON.parse(value);
846
- return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
847
- }
848
- catch {
849
- return [];
850
- }
851
- }
852
311
  function workspaceMatches(workspace, installation) {
853
312
  const normalized = workspace.trim().toLowerCase();
854
313
  return [