patchrelay 0.37.1 → 0.38.1

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 +47 -9
  2. package/dist/awaiting-input-reason.js +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/cluster-health.js +59 -3
  5. package/dist/cli/help.js +1 -1
  6. package/dist/cli/output.js +2 -0
  7. package/dist/db/issue-session-store.js +0 -14
  8. package/dist/db/issue-store.js +8 -16
  9. package/dist/db/migrations.js +6 -13
  10. package/dist/db.js +1 -3
  11. package/dist/github-linear-session-sync.js +57 -0
  12. package/dist/github-pr-comment-handler.js +74 -0
  13. package/dist/github-webhook-failure-context.js +70 -0
  14. package/dist/github-webhook-handler.js +49 -965
  15. package/dist/github-webhook-issue-resolution.js +46 -0
  16. package/dist/github-webhook-policy.js +105 -0
  17. package/dist/github-webhook-reactive-run.js +302 -0
  18. package/dist/github-webhook-state-projector.js +231 -0
  19. package/dist/github-webhook-terminal-handler.js +111 -0
  20. package/dist/github-webhooks.js +4 -0
  21. package/dist/idle-reconciliation.js +22 -23
  22. package/dist/issue-overview-query.js +11 -57
  23. package/dist/issue-session-projector.js +1 -0
  24. package/dist/issue-session.js +8 -0
  25. package/dist/legacy-issue-overview.js +58 -0
  26. package/dist/linear-session-reporting.js +30 -1
  27. package/dist/linear-session-sync.js +9 -1
  28. package/dist/linear-status-comment-sync.js +34 -1
  29. package/dist/linear-workflow-state-sync.js +2 -2
  30. package/dist/operator-retry-event.js +15 -12
  31. package/dist/paused-issue-state.js +24 -0
  32. package/dist/reactive-pr-state.js +65 -0
  33. package/dist/reactive-run-policy.js +35 -118
  34. package/dist/remote-pr-state.js +11 -0
  35. package/dist/run-launcher.js +0 -1
  36. package/dist/run-orchestrator.js +22 -11
  37. package/dist/run-reconciler.js +10 -0
  38. package/dist/run-recovery-service.js +1 -10
  39. package/dist/service-issue-actions.js +5 -0
  40. package/dist/service-startup-recovery.js +9 -6
  41. package/dist/service.js +0 -1
  42. package/dist/tracked-issue-list-query.js +3 -1
  43. package/dist/tracked-issue-projector.js +3 -0
  44. package/dist/waiting-reason.js +10 -0
  45. package/dist/webhooks/agent-session-handler.js +9 -1
  46. package/dist/webhooks/comment-wake-handler.js +12 -0
  47. package/dist/webhooks/decision-helpers.js +44 -3
  48. package/dist/webhooks/dependency-readiness-handler.js +1 -0
  49. package/dist/webhooks/desired-stage-recorder.js +40 -10
  50. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
2
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
+ import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
+ export async function handleGitHubTerminalPrEvent(params) {
5
+ const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
6
+ const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
7
+ db.issueSessions.appendIssueSessionEvent({
8
+ projectId: issue.projectId,
9
+ linearIssueId: issue.linearIssueId,
10
+ eventType,
11
+ dedupeKey: [eventType, issue.prNumber ?? event.prNumber ?? "unknown-pr", issue.prHeadSha ?? event.headSha ?? "unknown-sha"].join("::"),
12
+ });
13
+ db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
14
+ const run = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
15
+ if (run?.threadId && run.turnId) {
16
+ try {
17
+ await codex.steerTurn({
18
+ threadId: run.threadId,
19
+ turnId: run.turnId,
20
+ input: event.triggerEvent === "pr_merged"
21
+ ? "STOP: The pull request has already merged. Stop working immediately and exit without making further changes."
22
+ : "STOP: The pull request was closed. Stop working immediately and exit without making further changes.",
23
+ });
24
+ }
25
+ catch (error) {
26
+ logger.warn({ issueKey: issue.issueKey, runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run after terminal PR event");
27
+ }
28
+ }
29
+ const commitTerminalUpdate = () => {
30
+ if (run) {
31
+ db.runs.finishRun(run.id, {
32
+ status: "released",
33
+ failureReason: event.triggerEvent === "pr_merged"
34
+ ? "Pull request merged during active run"
35
+ : "Pull request closed during active run",
36
+ });
37
+ }
38
+ const terminalFactoryState = event.triggerEvent === "pr_merged"
39
+ ? "done"
40
+ : resolveClosedPrFactoryState(issue);
41
+ db.issues.upsertIssue({
42
+ projectId: issue.projectId,
43
+ linearIssueId: issue.linearIssueId,
44
+ activeRunId: null,
45
+ factoryState: terminalFactoryState,
46
+ });
47
+ };
48
+ const activeLease = db.issueSessions.getActiveIssueSessionLease(issue.projectId, issue.linearIssueId);
49
+ if (activeLease) {
50
+ db.issueSessions.withIssueSessionLease(issue.projectId, issue.linearIssueId, activeLease.leaseId, commitTerminalUpdate);
51
+ }
52
+ else {
53
+ db.transaction(commitTerminalUpdate);
54
+ }
55
+ db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
56
+ const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
57
+ if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
58
+ db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
59
+ projectId: issue.projectId,
60
+ linearIssueId: issue.linearIssueId,
61
+ eventType: "delegated",
62
+ dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
63
+ });
64
+ if (db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
65
+ enqueueIssue(issue.projectId, issue.linearIssueId);
66
+ }
67
+ }
68
+ if (event.triggerEvent === "pr_merged") {
69
+ await completeLinearIssueAfterMerge(params, updatedIssue);
70
+ }
71
+ void syncGitHubLinearSession({
72
+ config,
73
+ linearProvider,
74
+ logger,
75
+ issue: updatedIssue,
76
+ });
77
+ }
78
+ async function completeLinearIssueAfterMerge(params, issue) {
79
+ const linear = await params.linearProvider.forProject(issue.projectId).catch(() => undefined);
80
+ if (!linear)
81
+ return;
82
+ try {
83
+ const liveIssue = await linear.getIssue(issue.linearIssueId);
84
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
85
+ if (!targetState) {
86
+ params.logger.warn({ issueKey: issue.issueKey }, "Could not find a completed Linear workflow state after merge");
87
+ return;
88
+ }
89
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
90
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
91
+ params.db.issues.upsertIssue({
92
+ projectId: issue.projectId,
93
+ linearIssueId: issue.linearIssueId,
94
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
95
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
96
+ });
97
+ return;
98
+ }
99
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
100
+ params.db.issues.upsertIssue({
101
+ projectId: issue.projectId,
102
+ linearIssueId: issue.linearIssueId,
103
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
104
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
105
+ });
106
+ }
107
+ catch (error) {
108
+ const msg = error instanceof Error ? error.message : String(error);
109
+ params.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to move merged issue to a completed Linear state");
110
+ }
111
+ }
@@ -61,6 +61,8 @@ function normalizePullRequestEvent(payload, repoFullName) {
61
61
  repoFullName,
62
62
  branchName: pr.head.ref,
63
63
  headSha: pr.head.sha,
64
+ prTitle: pr.title ?? undefined,
65
+ prBody: pr.body ?? undefined,
64
66
  prNumber: pr.number,
65
67
  prUrl: pr.html_url,
66
68
  prState,
@@ -98,6 +100,8 @@ function normalizePullRequestReviewEvent(payload, repoFullName) {
98
100
  repoFullName,
99
101
  branchName: pr.head.ref,
100
102
  headSha: pr.head.sha,
103
+ prTitle: pr.title ?? undefined,
104
+ prBody: pr.body ?? undefined,
101
105
  prNumber: pr.number,
102
106
  prUrl: pr.html_url,
103
107
  prState: "open",
@@ -1,4 +1,3 @@
1
- import {} from "./factory-state.js";
2
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
2
  import { parseGitHubFailureContext } from "./github-failure-context.js";
4
3
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
@@ -90,15 +89,6 @@ function hasFailureProvenance(issue) {
90
89
  || issue.lastAttemptedFailureHeadSha
91
90
  || issue.lastAttemptedFailureSignature);
92
91
  }
93
- export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
94
- if (pendingRunType)
95
- return "patchrelay";
96
- if (newState === "awaiting_queue")
97
- return "patchrelay";
98
- if (newState === "repairing_ci" || newState === "repairing_queue")
99
- return "patchrelay";
100
- return undefined;
101
- }
102
92
  export class IdleIssueReconciler {
103
93
  db;
104
94
  config;
@@ -154,6 +144,8 @@ export class IdleIssueReconciler {
154
144
  await this.reconcileFromGitHub(issue);
155
145
  }
156
146
  for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
147
+ if (!issue.delegatedToPatchRelay)
148
+ continue;
157
149
  const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
158
150
  if (unresolved === 0) {
159
151
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
@@ -207,10 +199,6 @@ export class IdleIssueReconciler {
207
199
  }
208
200
  : {}),
209
201
  });
210
- const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
211
- if (branchOwner) {
212
- this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
213
- }
214
202
  if (options?.pendingRunType) {
215
203
  this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
216
204
  }
@@ -255,6 +243,9 @@ export class IdleIssueReconciler {
255
243
  });
256
244
  }
257
245
  async routeFailedIssue(issue) {
246
+ if (!issue.delegatedToPatchRelay) {
247
+ return;
248
+ }
258
249
  issue = await this.refreshMissingFailureProvenance(issue);
259
250
  issue = await this.reclassifyStaleBranchFailure(issue);
260
251
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
@@ -457,11 +448,17 @@ export class IdleIssueReconciler {
457
448
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
458
449
  return;
459
450
  }
460
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished work, re-delegating for implementation");
461
- this.advanceIdleIssue(issue, "delegated", {
462
- pendingRunType: "implementation",
463
- clearFailureProvenance: true,
464
- });
451
+ if (issue.delegatedToPatchRelay) {
452
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed on unfinished delegated work, re-delegating for implementation");
453
+ this.advanceIdleIssue(issue, "delegated", {
454
+ pendingRunType: "implementation",
455
+ clearFailureProvenance: true,
456
+ });
457
+ }
458
+ else {
459
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed while undelegated; preserving paused local-work state");
460
+ this.advanceIdleIssue(issue, "delegated", { clearFailureProvenance: true });
461
+ }
465
462
  return;
466
463
  }
467
464
  const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
@@ -481,7 +478,8 @@ export class IdleIssueReconciler {
481
478
  return;
482
479
  }
483
480
  }
484
- if (isReviewDecisionReviewRequired(pr.reviewDecision)
481
+ if (issue.delegatedToPatchRelay
482
+ && isReviewDecisionReviewRequired(pr.reviewDecision)
485
483
  && gateCheckStatus === "success"
486
484
  && hasCompletedReviewQuillVerdict(pr.statusCheckRollup)) {
487
485
  this.logger.warn({ issueKey: issue.issueKey, prNumber: issue.prNumber, reviewDecision: pr.reviewDecision }, "Reconciliation: review-quill completed without a decisive GitHub review; escalating for operator input");
@@ -509,7 +507,8 @@ export class IdleIssueReconciler {
509
507
  mergeConflictDetected,
510
508
  downstreamOwned,
511
509
  });
512
- if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
510
+ if (issue.delegatedToPatchRelay
511
+ && (issue.factoryState === "escalated" || issue.factoryState === "failed")
513
512
  && (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
514
513
  if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
515
514
  this.logger.debug({
@@ -538,7 +537,7 @@ export class IdleIssueReconciler {
538
537
  });
539
538
  return;
540
539
  }
541
- if (reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
540
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "branch_upkeep" && mergeConflictDetected) {
542
541
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
543
542
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
544
543
  pendingRunType: reactiveIntent.runType,
@@ -555,7 +554,7 @@ export class IdleIssueReconciler {
555
554
  });
556
555
  return;
557
556
  }
558
- if (reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
557
+ if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) {
559
558
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth");
560
559
  this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
561
560
  pendingRunType: reactiveIntent.runType,
@@ -1,5 +1,6 @@
1
1
  import { parseGitHubFailureContext } from "./github-failure-context.js";
2
2
  import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
+ import { getLegacyIssueOverview } from "./legacy-issue-overview.js";
3
4
  import { deriveIssueStatusNote } from "./status-note.js";
4
5
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
6
  export function parseStageReport(reportJson, runStatus) {
@@ -25,7 +26,13 @@ export class IssueOverviewQuery {
25
26
  async getIssueOverview(issueKey) {
26
27
  const session = this.db.issueSessions.getIssueSessionByKey(issueKey);
27
28
  if (!session) {
28
- return await this.getLegacyIssueOverview(issueKey);
29
+ return await getLegacyIssueOverview({
30
+ db: this.db,
31
+ issueKey,
32
+ runStatusProvider: this.runStatusProvider,
33
+ buildRuns: (projectId, linearIssueId) => this.buildRuns(projectId, linearIssueId),
34
+ readLiveThread: (run) => this.readLiveThread(run),
35
+ });
29
36
  }
30
37
  return await this.getSessionIssueOverview(issueKey, session);
31
38
  }
@@ -66,62 +73,6 @@ export class IssueOverviewQuery {
66
73
  })(),
67
74
  }));
68
75
  }
69
- async getLegacyIssueOverview(issueKey) {
70
- const legacy = this.db.getIssueOverview(issueKey);
71
- if (!legacy)
72
- return undefined;
73
- const issueRecord = this.db.issues.getIssueByKey(issueKey);
74
- const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
75
- const activeRun = activeStatus?.run ?? legacy.activeRun;
76
- const latestRun = this.db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
77
- const latestEvent = this.db.issueSessions.listIssueSessionEvents(legacy.issue.projectId, legacy.issue.linearIssueId, { limit: 1 }).at(-1);
78
- const runs = this.buildRuns(legacy.issue.projectId, legacy.issue.linearIssueId);
79
- const runCount = runs.length;
80
- const liveThread = await this.readLiveThread(activeRun);
81
- const statusNote = issueRecord
82
- ? deriveIssueStatusNote({
83
- issue: issueRecord,
84
- latestRun,
85
- latestEvent,
86
- failureSummary: legacy.issue.latestFailureSummary,
87
- blockedByKeys: legacy.issue.blockedByKeys,
88
- waitingReason: legacy.issue.waitingReason,
89
- })
90
- : legacy.issue.statusNote;
91
- return {
92
- issue: {
93
- ...legacy.issue,
94
- ...(statusNote ? { statusNote } : {}),
95
- },
96
- ...(activeRun ? { activeRun } : {}),
97
- ...(latestRun ? { latestRun } : {}),
98
- ...(liveThread ? { liveThread } : {}),
99
- ...(runs.length > 0 ? { runs } : {}),
100
- ...(issueRecord
101
- ? {
102
- issueContext: {
103
- ...(issueRecord.description ? { description: issueRecord.description } : {}),
104
- ...(issueRecord.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
105
- ...(issueRecord.url ? { issueUrl: issueRecord.url } : {}),
106
- ...(issueRecord.worktreePath ? { worktreePath: issueRecord.worktreePath } : {}),
107
- ...(issueRecord.branchName ? { branchName: issueRecord.branchName } : {}),
108
- ...(issueRecord.prUrl ? { prUrl: issueRecord.prUrl } : {}),
109
- ...(issueRecord.priority != null ? { priority: issueRecord.priority } : {}),
110
- ...(issueRecord.estimate != null ? { estimate: issueRecord.estimate } : {}),
111
- ciRepairAttempts: issueRecord.ciRepairAttempts,
112
- queueRepairAttempts: issueRecord.queueRepairAttempts,
113
- reviewFixAttempts: issueRecord.reviewFixAttempts,
114
- ...(legacy.issue.latestFailureSource ? { latestFailureSource: legacy.issue.latestFailureSource } : {}),
115
- ...(legacy.issue.latestFailureHeadSha ? { latestFailureHeadSha: legacy.issue.latestFailureHeadSha } : {}),
116
- ...(legacy.issue.latestFailureCheckName ? { latestFailureCheckName: legacy.issue.latestFailureCheckName } : {}),
117
- ...(legacy.issue.latestFailureStepName ? { latestFailureStepName: legacy.issue.latestFailureStepName } : {}),
118
- ...(legacy.issue.latestFailureSummary ? { latestFailureSummary: legacy.issue.latestFailureSummary } : {}),
119
- runCount,
120
- },
121
- }
122
- : {}),
123
- };
124
- }
125
76
  async getSessionIssueOverview(issueKey, session) {
126
77
  const issueRecord = this.db.issues.getIssueByKey(issueKey);
127
78
  const blockedBy = this.db.issues.listIssueDependencies(session.projectId, session.linearIssueId);
@@ -138,6 +89,7 @@ export class IssueOverviewQuery {
138
89
  const liveThread = await this.readLiveThread(activeRun);
139
90
  const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
140
91
  const waitingReason = session.waitingReason ?? derivePatchRelayWaitingReason({
92
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
141
93
  ...(activeRun ? { activeRunType: activeRun.runType } : {}),
142
94
  blockedByKeys,
143
95
  factoryState: issueRecord?.factoryState ?? "delegated",
@@ -154,6 +106,7 @@ export class IssueOverviewQuery {
154
106
  id: issueRecord?.id ?? session.id,
155
107
  projectId: session.projectId,
156
108
  linearIssueId: session.linearIssueId,
109
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay ?? true,
157
110
  ...(session.issueKey ? { issueKey: session.issueKey } : {}),
158
111
  ...(issueRecord?.title ? { title: issueRecord.title } : {}),
159
112
  ...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
@@ -169,6 +122,7 @@ export class IssueOverviewQuery {
169
122
  readyForExecution: isIssueSessionReadyForExecution({
170
123
  sessionState: session.sessionState,
171
124
  factoryState: issueRecord?.factoryState ?? "delegated",
125
+ delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
172
126
  ...(activeRun ? { activeRunId: activeRun.id } : {}),
173
127
  blockedByCount: unresolvedBlockedBy.length,
174
128
  hasPendingWake: this.db.issueSessions.peekIssueSessionWake(session.projectId, session.linearIssueId) !== undefined,
@@ -26,6 +26,7 @@ export function syncIssueSessionFromIssue(params) {
26
26
  });
27
27
  const lastWakeReason = options?.lastWakeReason
28
28
  ?? deriveIssueSessionWakeReason({
29
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
29
30
  pendingRunType: issue.pendingRunType,
30
31
  factoryState: issue.factoryState,
31
32
  prNumber: issue.prNumber,
@@ -14,6 +14,8 @@ export function deriveIssueSessionWaitingReason(params) {
14
14
  return derivePatchRelayWaitingReason(params);
15
15
  }
16
16
  export function deriveIssueSessionWakeReason(params) {
17
+ if (params.delegatedToPatchRelay === false)
18
+ return undefined;
17
19
  if (params.pendingRunType === "implementation")
18
20
  return "delegated";
19
21
  if (params.pendingRunType === "review_fix")
@@ -27,6 +29,7 @@ export function deriveIssueSessionWakeReason(params) {
27
29
  if (params.factoryState === "awaiting_input")
28
30
  return "waiting_for_human_reply";
29
31
  const reactiveIntent = deriveIssueSessionReactiveIntent({
32
+ delegatedToPatchRelay: params.delegatedToPatchRelay,
30
33
  prNumber: params.prNumber,
31
34
  prState: params.prState,
32
35
  prReviewState: params.prReviewState,
@@ -38,6 +41,8 @@ export function deriveIssueSessionWakeReason(params) {
38
41
  return undefined;
39
42
  }
40
43
  export function deriveIssueSessionReactiveIntent(params) {
44
+ if (params.delegatedToPatchRelay === false)
45
+ return undefined;
41
46
  if (params.activeRunId !== undefined)
42
47
  return undefined;
43
48
  if (params.prNumber === undefined)
@@ -75,6 +80,8 @@ export function deriveIssueSessionReactiveIntent(params) {
75
80
  return undefined;
76
81
  }
77
82
  export function isIssueSessionReadyForExecution(params) {
83
+ if (params.delegatedToPatchRelay === false)
84
+ return false;
78
85
  if (params.activeRunId !== undefined)
79
86
  return false;
80
87
  if (params.blockedByCount > 0)
@@ -92,6 +99,7 @@ export function isIssueSessionReadyForExecution(params) {
92
99
  return false;
93
100
  }
94
101
  if (deriveIssueSessionReactiveIntent({
102
+ delegatedToPatchRelay: params.delegatedToPatchRelay,
95
103
  prNumber: params.prNumber,
96
104
  prState: params.prState,
97
105
  prReviewState: params.prReviewState,
@@ -0,0 +1,58 @@
1
+ import { deriveIssueStatusNote } from "./status-note.js";
2
+ export async function getLegacyIssueOverview(params) {
3
+ const { db, issueKey, runStatusProvider, buildRuns, readLiveThread } = params;
4
+ const legacy = db.getIssueOverview(issueKey);
5
+ if (!legacy)
6
+ return undefined;
7
+ const issueRecord = db.issues.getIssueByKey(issueKey);
8
+ const activeStatus = await runStatusProvider.getActiveRunStatus(issueKey);
9
+ const activeRun = activeStatus?.run ?? legacy.activeRun;
10
+ const latestRun = db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
11
+ const latestEvent = db.issueSessions.listIssueSessionEvents(legacy.issue.projectId, legacy.issue.linearIssueId, { limit: 1 }).at(-1);
12
+ const runs = buildRuns(legacy.issue.projectId, legacy.issue.linearIssueId);
13
+ const runCount = runs.length;
14
+ const liveThread = await readLiveThread(activeRun);
15
+ const statusNote = issueRecord
16
+ ? deriveIssueStatusNote({
17
+ issue: issueRecord,
18
+ latestRun,
19
+ latestEvent,
20
+ failureSummary: legacy.issue.latestFailureSummary,
21
+ blockedByKeys: legacy.issue.blockedByKeys,
22
+ waitingReason: legacy.issue.waitingReason,
23
+ })
24
+ : legacy.issue.statusNote;
25
+ return {
26
+ issue: {
27
+ ...legacy.issue,
28
+ ...(statusNote ? { statusNote } : {}),
29
+ },
30
+ ...(activeRun ? { activeRun } : {}),
31
+ ...(latestRun ? { latestRun } : {}),
32
+ ...(liveThread ? { liveThread } : {}),
33
+ ...(runs.length > 0 ? { runs } : {}),
34
+ ...(issueRecord
35
+ ? {
36
+ issueContext: {
37
+ ...(issueRecord.description ? { description: issueRecord.description } : {}),
38
+ ...(issueRecord.currentLinearState ? { currentLinearState: issueRecord.currentLinearState } : {}),
39
+ ...(issueRecord.url ? { issueUrl: issueRecord.url } : {}),
40
+ ...(issueRecord.worktreePath ? { worktreePath: issueRecord.worktreePath } : {}),
41
+ ...(issueRecord.branchName ? { branchName: issueRecord.branchName } : {}),
42
+ ...(issueRecord.prUrl ? { prUrl: issueRecord.prUrl } : {}),
43
+ ...(issueRecord.priority != null ? { priority: issueRecord.priority } : {}),
44
+ ...(issueRecord.estimate != null ? { estimate: issueRecord.estimate } : {}),
45
+ ciRepairAttempts: issueRecord.ciRepairAttempts,
46
+ queueRepairAttempts: issueRecord.queueRepairAttempts,
47
+ reviewFixAttempts: issueRecord.reviewFixAttempts,
48
+ ...(legacy.issue.latestFailureSource ? { latestFailureSource: legacy.issue.latestFailureSource } : {}),
49
+ ...(legacy.issue.latestFailureHeadSha ? { latestFailureHeadSha: legacy.issue.latestFailureHeadSha } : {}),
50
+ ...(legacy.issue.latestFailureCheckName ? { latestFailureCheckName: legacy.issue.latestFailureCheckName } : {}),
51
+ ...(legacy.issue.latestFailureStepName ? { latestFailureStepName: legacy.issue.latestFailureStepName } : {}),
52
+ ...(legacy.issue.latestFailureSummary ? { latestFailureSummary: legacy.issue.latestFailureSummary } : {}),
53
+ runCount,
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ }
@@ -193,7 +193,10 @@ export function summarizeIssueStateForLinear(issue) {
193
193
  case "running":
194
194
  return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
195
195
  case "idle":
196
- return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} is idle.` : "Idle.");
196
+ if (!issue.delegatedToPatchRelay) {
197
+ break;
198
+ }
199
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
197
200
  case "done":
198
201
  if (issue.prNumber && issue.prState === "merged")
199
202
  return `PR #${issue.prNumber} has merged.`;
@@ -204,9 +207,35 @@ export function summarizeIssueStateForLinear(issue) {
204
207
  return issue.waitingReason ?? (issue.prNumber && !isClosedPrState(issue.prState) ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
205
208
  }
206
209
  switch (issue.factoryState) {
210
+ case "delegated":
211
+ if (!issue.delegatedToPatchRelay) {
212
+ return "PatchRelay is queued to start work, but automation is paused.";
213
+ }
214
+ return "Queued to start work.";
215
+ case "implementing":
216
+ if (!issue.delegatedToPatchRelay) {
217
+ return "Implementation is paused because the issue is undelegated.";
218
+ }
219
+ return "Implementation in progress.";
207
220
  case "pr_open":
221
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
222
+ return `PR #${issue.prNumber} is awaiting review while PatchRelay is paused.`;
223
+ }
208
224
  return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
225
+ case "changes_requested":
226
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
227
+ return `PR #${issue.prNumber} has requested changes while PatchRelay is paused.`;
228
+ }
229
+ return issue.prNumber ? `PR #${issue.prNumber} has requested changes.` : "Requested changes received.";
230
+ case "repairing_ci":
231
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
232
+ return `PR #${issue.prNumber} has failing CI while PatchRelay is paused.`;
233
+ }
234
+ return issue.prNumber ? `PR #${issue.prNumber} has failing CI.` : "Failing CI.";
209
235
  case "awaiting_queue":
236
+ if (!issue.delegatedToPatchRelay && issue.prNumber) {
237
+ return `PR #${issue.prNumber} is approved and awaiting merge while PatchRelay is paused.`;
238
+ }
210
239
  return issue.prNumber ? `PR #${issue.prNumber} is approved and awaiting merge.` : "Approved and awaiting merge.";
211
240
  case "done":
212
241
  if (issue.prNumber && issue.prState === "merged")
@@ -29,9 +29,17 @@ export class LinearSessionSync {
29
29
  if (!linear)
30
30
  return;
31
31
  const trackedIssue = this.db.getTrackedIssue(syncedIssue.projectId, syncedIssue.linearIssueId);
32
+ const visibleIssue = trackedIssue
33
+ ? {
34
+ ...trackedIssue,
35
+ delegatedToPatchRelay: syncedIssue.delegatedToPatchRelay,
36
+ prNumber: syncedIssue.prNumber,
37
+ prUrl: syncedIssue.prUrl,
38
+ }
39
+ : syncedIssue;
32
40
  await syncActiveWorkflowState({ db: this.db, issue: syncedIssue, linear, ...(trackedIssue ? { trackedIssue } : {}), ...(options ? { options } : {}) });
33
41
  await this.agentSessions.syncSessionPlan(syncedIssue, linear, options);
34
- if (shouldSyncVisibleIssueComment(trackedIssue ?? syncedIssue, Boolean(syncedIssue.agentSessionId))) {
42
+ if (shouldSyncVisibleIssueComment(visibleIssue, Boolean(syncedIssue.agentSessionId))) {
35
43
  await syncVisibleStatusComment({
36
44
  db: this.db,
37
45
  issue: syncedIssue,
@@ -2,6 +2,7 @@ import { extractCompletionCheck } from "./completion-check.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
4
4
  import { isClosedPrState } from "./pr-state.js";
5
+ import { isUndelegatedPausedIssue } from "./paused-issue-state.js";
5
6
  export async function syncVisibleStatusComment(params) {
6
7
  const { db, issue, linear, logger, trackedIssue, options } = params;
7
8
  try {
@@ -32,6 +33,9 @@ export function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
32
33
  || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
33
34
  return true;
34
35
  }
36
+ if (isUndelegatedPausedIssue(issue)) {
37
+ return true;
38
+ }
35
39
  if ((issue.sessionState === "done" || issue.factoryState === "done")
36
40
  && ((issue.prNumber === undefined && !issue.prUrl)
37
41
  || isClosedPrState(issue.prState))) {
@@ -47,6 +51,7 @@ function renderStatusComment(db, issue, trackedIssue, options) {
47
51
  ? (options?.activeRunType ?? activeRun?.runType)
48
52
  : undefined;
49
53
  const waitingReason = trackedIssue?.waitingReason ?? derivePatchRelayWaitingReason({
54
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
50
55
  ...(activeRunType ? { activeRunType } : {}),
51
56
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
52
57
  factoryState: issue.factoryState,
@@ -62,7 +67,15 @@ function renderStatusComment(db, issue, trackedIssue, options) {
62
67
  const lines = [
63
68
  "## PatchRelay status",
64
69
  "",
65
- statusHeadline(trackedIssue ?? issue, activeRunType),
70
+ statusHeadline(trackedIssue
71
+ ? {
72
+ ...trackedIssue,
73
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
74
+ prNumber: issue.prNumber,
75
+ prReviewState: issue.prReviewState,
76
+ prCheckStatus: issue.prCheckStatus,
77
+ }
78
+ : issue, activeRunType),
66
79
  ];
67
80
  const statusNote = trackedIssue?.statusNote ?? deriveIssueStatusNote({ issue, latestRun, latestEvent, waitingReason });
68
81
  if (waitingReason) {
@@ -124,6 +137,26 @@ function statusHeadline(issue, activeRunType) {
124
137
  default:
125
138
  break;
126
139
  }
140
+ if (!issue.delegatedToPatchRelay && issue.prNumber !== undefined) {
141
+ if (issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved") {
142
+ return `PR #${issue.prNumber} is awaiting downstream merge while PatchRelay is paused`;
143
+ }
144
+ if (issue.factoryState === "changes_requested" || issue.prReviewState === "changes_requested") {
145
+ return `PR #${issue.prNumber} has requested changes while PatchRelay is paused`;
146
+ }
147
+ if (issue.factoryState === "repairing_ci" || issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
148
+ return `PR #${issue.prNumber} has failing CI while PatchRelay is paused`;
149
+ }
150
+ return `PR #${issue.prNumber} is awaiting review while PatchRelay is paused`;
151
+ }
152
+ if (!issue.delegatedToPatchRelay) {
153
+ if (issue.factoryState === "implementing") {
154
+ return "Implementation is paused because the issue is undelegated";
155
+ }
156
+ if (issue.factoryState === "delegated") {
157
+ return "Queued to start work while PatchRelay is paused";
158
+ }
159
+ }
127
160
  switch (issue.factoryState) {
128
161
  case "delegated":
129
162
  return "Queued to start work";
@@ -53,14 +53,14 @@ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIss
53
53
  || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
54
54
  return resolvePreferredHumanNeededLinearState(liveIssue);
55
55
  }
56
- const activelyWorking = issue.activeRunId !== undefined
56
+ const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
57
57
  || options?.activeRunType !== undefined
58
58
  || trackedIssue?.sessionState === "running"
59
59
  || issue.factoryState === "delegated"
60
60
  || issue.factoryState === "implementing"
61
61
  || issue.factoryState === "changes_requested"
62
62
  || issue.factoryState === "repairing_ci"
63
- || issue.factoryState === "repairing_queue";
63
+ || issue.factoryState === "repairing_queue");
64
64
  if (activelyWorking) {
65
65
  return resolvePreferredImplementingLinearState(liveIssue);
66
66
  }