patchrelay 0.36.8 → 0.36.10
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/build-info.json +3 -3
- package/dist/cli/cluster-health.js +1 -1
- package/dist/cli/data.js +12 -10
- package/dist/cli/formatters/text.js +3 -1
- package/dist/db/issue-session-store.js +15 -23
- package/dist/db/issue-store.js +559 -0
- package/dist/db/run-store.js +10 -12
- package/dist/db.js +37 -625
- package/dist/github-webhook-handler.js +36 -20
- package/dist/idle-reconciliation.js +26 -15
- package/dist/interrupted-run-recovery.js +176 -0
- package/dist/issue-query-service.js +4 -4
- package/dist/issue-session-projector.js +114 -0
- package/dist/linear-session-sync.js +6 -6
- package/dist/queue-health-monitor.js +3 -3
- package/dist/run-completion-policy.js +412 -0
- package/dist/run-finalizer.js +34 -23
- package/dist/run-launcher.js +5 -5
- package/dist/run-orchestrator.js +46 -684
- package/dist/run-recovery-service.js +26 -18
- package/dist/run-wake-planner.js +1 -1
- package/dist/service.js +9 -9
- package/dist/webhook-handler.js +5 -5
- package/dist/webhooks/agent-session-handler.js +7 -7
- package/dist/webhooks/comment-wake-handler.js +1 -1
- package/dist/webhooks/desired-stage-recorder.js +5 -5
- package/dist/webhooks/issue-removal-handler.js +3 -3
- package/dist/worktree-manager.js +69 -0
- package/dist/zombie-recovery.js +13 -0
- package/package.json +1 -1
|
@@ -130,14 +130,15 @@ export class GitHubWebhookHandler {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
// Route to issue via branch name
|
|
133
|
-
const issue = this.db.getIssueByBranch(event.branchName);
|
|
133
|
+
const issue = this.db.issues.getIssueByBranch(event.branchName);
|
|
134
134
|
if (!issue) {
|
|
135
135
|
this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue for branch");
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
138
138
|
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
139
|
+
const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
|
|
139
140
|
// Update PR state on the issue
|
|
140
|
-
this.db.upsertIssue({
|
|
141
|
+
this.db.issues.upsertIssue({
|
|
141
142
|
projectId: issue.projectId,
|
|
142
143
|
linearIssueId: issue.linearIssueId,
|
|
143
144
|
...(event.prNumber !== undefined ? { prNumber: event.prNumber } : {}),
|
|
@@ -146,7 +147,7 @@ export class GitHubWebhookHandler {
|
|
|
146
147
|
...(event.headSha !== undefined ? { prHeadSha: event.headSha } : {}),
|
|
147
148
|
...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
|
|
148
149
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
149
|
-
...(
|
|
150
|
+
...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
|
|
150
151
|
...(event.reviewState === "changes_requested"
|
|
151
152
|
? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
|
|
152
153
|
: event.reviewState === "approved"
|
|
@@ -158,7 +159,7 @@ export class GitHubWebhookHandler {
|
|
|
158
159
|
const queueEvictionCheck = this.isQueueEvictionFailure(issue, event, project);
|
|
159
160
|
if (!isMetadataOnlyCheckEvent(event) || queueEvictionCheck) {
|
|
160
161
|
// Re-read issue after PR metadata upsert so guards see fresh prReviewState
|
|
161
|
-
const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
162
|
+
const afterMetadata = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
162
163
|
const newState = this.resolveFactoryStateForEvent(afterMetadata, event, project);
|
|
163
164
|
// Only transition and notify when the state actually changes.
|
|
164
165
|
// Multiple check_suite events can arrive for the same outcome.
|
|
@@ -169,13 +170,13 @@ export class GitHubWebhookHandler {
|
|
|
169
170
|
factoryState: newState,
|
|
170
171
|
});
|
|
171
172
|
this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
|
|
172
|
-
const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
173
|
+
const transitionedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
173
174
|
void this.emitLinearActivity(transitionedIssue, newState, event);
|
|
174
175
|
void this.syncLinearSession(transitionedIssue);
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
// Re-read issue after all upserts so reactive run logic sees current state
|
|
178
|
-
const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
179
|
+
const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
179
180
|
// Reset repair counters on new push — but only when no repair run is active,
|
|
180
181
|
// since Codex pushes during repair and resetting mid-run would bypass budgets.
|
|
181
182
|
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
@@ -240,7 +241,7 @@ export class GitHubWebhookHandler {
|
|
|
240
241
|
}
|
|
241
242
|
async updateCiSnapshot(issue, event, project) {
|
|
242
243
|
if (event.triggerEvent === "pr_merged") {
|
|
243
|
-
this.db.upsertIssue({
|
|
244
|
+
this.db.issues.upsertIssue({
|
|
244
245
|
projectId: issue.projectId,
|
|
245
246
|
linearIssueId: issue.linearIssueId,
|
|
246
247
|
lastGitHubCiSnapshotHeadSha: null,
|
|
@@ -252,7 +253,7 @@ export class GitHubWebhookHandler {
|
|
|
252
253
|
return;
|
|
253
254
|
}
|
|
254
255
|
if (event.triggerEvent === "pr_synchronize") {
|
|
255
|
-
this.db.upsertIssue({
|
|
256
|
+
this.db.issues.upsertIssue({
|
|
256
257
|
projectId: issue.projectId,
|
|
257
258
|
linearIssueId: issue.linearIssueId,
|
|
258
259
|
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
@@ -279,7 +280,7 @@ export class GitHubWebhookHandler {
|
|
|
279
280
|
gateCheckNames: this.getGateCheckNames(project),
|
|
280
281
|
});
|
|
281
282
|
if (!snapshot) {
|
|
282
|
-
this.db.upsertIssue({
|
|
283
|
+
this.db.issues.upsertIssue({
|
|
283
284
|
projectId: issue.projectId,
|
|
284
285
|
linearIssueId: issue.linearIssueId,
|
|
285
286
|
lastGitHubCiSnapshotHeadSha: event.headSha ?? issue.lastGitHubCiSnapshotHeadSha ?? null,
|
|
@@ -300,7 +301,7 @@ export class GitHubWebhookHandler {
|
|
|
300
301
|
});
|
|
301
302
|
return;
|
|
302
303
|
}
|
|
303
|
-
this.db.upsertIssue({
|
|
304
|
+
this.db.issues.upsertIssue({
|
|
304
305
|
projectId: issue.projectId,
|
|
305
306
|
linearIssueId: issue.linearIssueId,
|
|
306
307
|
prCheckStatus: snapshot.gateCheckStatus,
|
|
@@ -341,7 +342,7 @@ export class GitHubWebhookHandler {
|
|
|
341
342
|
return;
|
|
342
343
|
}
|
|
343
344
|
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
344
|
-
this.db.upsertIssue({
|
|
345
|
+
this.db.issues.upsertIssue({
|
|
345
346
|
projectId: issue.projectId,
|
|
346
347
|
linearIssueId: issue.linearIssueId,
|
|
347
348
|
lastGitHubFailureSource: "queue_eviction",
|
|
@@ -399,7 +400,7 @@ export class GitHubWebhookHandler {
|
|
|
399
400
|
}
|
|
400
401
|
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
401
402
|
const snapshot = this.getRelevantCiSnapshot(issue, event);
|
|
402
|
-
this.db.upsertIssue({
|
|
403
|
+
this.db.issues.upsertIssue({
|
|
403
404
|
projectId: issue.projectId,
|
|
404
405
|
linearIssueId: issue.linearIssueId,
|
|
405
406
|
lastGitHubFailureSource: "branch_ci",
|
|
@@ -520,7 +521,7 @@ export class GitHubWebhookHandler {
|
|
|
520
521
|
: "Pull request closed during active run",
|
|
521
522
|
});
|
|
522
523
|
}
|
|
523
|
-
this.db.upsertIssue({
|
|
524
|
+
this.db.issues.upsertIssue({
|
|
524
525
|
projectId: issue.projectId,
|
|
525
526
|
linearIssueId: issue.linearIssueId,
|
|
526
527
|
activeRunId: null,
|
|
@@ -535,7 +536,7 @@ export class GitHubWebhookHandler {
|
|
|
535
536
|
this.db.transaction(commitTerminalUpdate);
|
|
536
537
|
}
|
|
537
538
|
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
538
|
-
const updatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
539
|
+
const updatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
539
540
|
if (event.triggerEvent === "pr_merged") {
|
|
540
541
|
await this.completeLinearIssueAfterMerge(updatedIssue);
|
|
541
542
|
}
|
|
@@ -554,7 +555,7 @@ export class GitHubWebhookHandler {
|
|
|
554
555
|
}
|
|
555
556
|
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
556
557
|
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
557
|
-
this.db.upsertIssue({
|
|
558
|
+
this.db.issues.upsertIssue({
|
|
558
559
|
projectId: issue.projectId,
|
|
559
560
|
linearIssueId: issue.linearIssueId,
|
|
560
561
|
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
@@ -563,7 +564,7 @@ export class GitHubWebhookHandler {
|
|
|
563
564
|
return;
|
|
564
565
|
}
|
|
565
566
|
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
566
|
-
this.db.upsertIssue({
|
|
567
|
+
this.db.issues.upsertIssue({
|
|
567
568
|
projectId: issue.projectId,
|
|
568
569
|
linearIssueId: issue.linearIssueId,
|
|
569
570
|
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
@@ -587,7 +588,7 @@ export class GitHubWebhookHandler {
|
|
|
587
588
|
const failureContext = source === "queue_eviction"
|
|
588
589
|
? this.buildQueueFailureContext(issue, event)
|
|
589
590
|
: await this.resolveBranchFailureContext(issue, event, project);
|
|
590
|
-
this.db.upsertIssue({
|
|
591
|
+
this.db.issues.upsertIssue({
|
|
591
592
|
projectId: issue.projectId,
|
|
592
593
|
linearIssueId: issue.linearIssueId,
|
|
593
594
|
lastGitHubFailureSource: source,
|
|
@@ -614,7 +615,7 @@ export class GitHubWebhookHandler {
|
|
|
614
615
|
if (event.triggerEvent === "check_passed" && !this.canClearFailureProvenance(issue, event, project)) {
|
|
615
616
|
return;
|
|
616
617
|
}
|
|
617
|
-
this.db.upsertIssue({
|
|
618
|
+
this.db.issues.upsertIssue({
|
|
618
619
|
projectId: issue.projectId,
|
|
619
620
|
linearIssueId: issue.linearIssueId,
|
|
620
621
|
lastGitHubFailureSource: null,
|
|
@@ -730,6 +731,21 @@ export class GitHubWebhookHandler {
|
|
|
730
731
|
const normalized = event.checkName.trim().toLowerCase();
|
|
731
732
|
return this.getGateCheckNames(project).some((entry) => entry.trim().toLowerCase() === normalized);
|
|
732
733
|
}
|
|
734
|
+
deriveImmediatePrCheckStatus(issue, event, project) {
|
|
735
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
736
|
+
return "pending";
|
|
737
|
+
}
|
|
738
|
+
if (event.eventSource !== "check_run") {
|
|
739
|
+
return undefined;
|
|
740
|
+
}
|
|
741
|
+
if (!this.isGateCheckEvent(event, project)) {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
if (this.isStaleGateEvent(issue, event)) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
return event.checkStatus;
|
|
748
|
+
}
|
|
733
749
|
isStaleGateEvent(issue, event) {
|
|
734
750
|
return Boolean(issue.lastGitHubCiSnapshotHeadSha
|
|
735
751
|
&& event.headSha
|
|
@@ -763,7 +779,7 @@ export class GitHubWebhookHandler {
|
|
|
763
779
|
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
764
780
|
}
|
|
765
781
|
getRelevantCiSnapshot(issue, event) {
|
|
766
|
-
const snapshot = this.db.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
|
|
782
|
+
const snapshot = this.db.issues.getLatestGitHubCiSnapshot(issue.projectId, issue.linearIssueId);
|
|
767
783
|
if (!snapshot)
|
|
768
784
|
return undefined;
|
|
769
785
|
if (snapshot.headSha !== event.headSha)
|
|
@@ -904,7 +920,7 @@ export class GitHubWebhookHandler {
|
|
|
904
920
|
const prNumber = typeof issuePayload.number === "number" ? issuePayload.number : undefined;
|
|
905
921
|
if (!prNumber)
|
|
906
922
|
return;
|
|
907
|
-
const issue = this.db.getIssueByPrNumber(prNumber);
|
|
923
|
+
const issue = this.db.issues.getIssueByPrNumber(prNumber);
|
|
908
924
|
if (!issue)
|
|
909
925
|
return;
|
|
910
926
|
if (!this.isPatchRelayOwnedPr(issue))
|
|
@@ -4,6 +4,7 @@ import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
|
|
|
4
4
|
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
5
5
|
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
6
6
|
import { execCommand } from "./utils.js";
|
|
7
|
+
const DEFAULT_REVIEW_FIX_BUDGET = 12;
|
|
7
8
|
function isFailingCheckStatus(status) {
|
|
8
9
|
return status === "failed" || status === "failure";
|
|
9
10
|
}
|
|
@@ -110,7 +111,7 @@ export class IdleIssueReconciler {
|
|
|
110
111
|
this.feed = feed;
|
|
111
112
|
}
|
|
112
113
|
async reconcile() {
|
|
113
|
-
for (const issue of this.db.listIdleNonTerminalIssues()) {
|
|
114
|
+
for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
|
|
114
115
|
if (issue.prState === "merged") {
|
|
115
116
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
116
117
|
continue;
|
|
@@ -145,13 +146,13 @@ export class IdleIssueReconciler {
|
|
|
145
146
|
await this.reconcileFromGitHub(issue);
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
|
-
for (const issue of this.db.listIssues()) {
|
|
149
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
149
150
|
if (!this.shouldProbeTerminalIssueFromGitHub(issue))
|
|
150
151
|
continue;
|
|
151
152
|
await this.reconcileFromGitHub(issue);
|
|
152
153
|
}
|
|
153
|
-
for (const issue of this.db.listBlockedDelegatedIssues()) {
|
|
154
|
-
const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
154
|
+
for (const issue of this.db.issues.listBlockedDelegatedIssues()) {
|
|
155
|
+
const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
155
156
|
if (unresolved === 0) {
|
|
156
157
|
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
157
158
|
projectId: issue.projectId,
|
|
@@ -179,7 +180,7 @@ export class IdleIssueReconciler {
|
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
|
|
182
|
-
this.db.upsertIssue({
|
|
183
|
+
this.db.issues.upsertIssue({
|
|
183
184
|
projectId: issue.projectId,
|
|
184
185
|
linearIssueId: issue.linearIssueId,
|
|
185
186
|
factoryState: newState,
|
|
@@ -206,7 +207,7 @@ export class IdleIssueReconciler {
|
|
|
206
207
|
});
|
|
207
208
|
const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
|
|
208
209
|
if (branchOwner) {
|
|
209
|
-
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
210
|
+
this.db.issues.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
210
211
|
}
|
|
211
212
|
if (options?.pendingRunType) {
|
|
212
213
|
this.appendWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
|
|
@@ -309,7 +310,7 @@ export class IdleIssueReconciler {
|
|
|
309
310
|
?? (inferred === "queue_eviction" && failureHeadSha && checkName
|
|
310
311
|
? ["queue_eviction", failureHeadSha, checkName].join("::")
|
|
311
312
|
: null);
|
|
312
|
-
this.db.upsertIssue({
|
|
313
|
+
this.db.issues.upsertIssue({
|
|
313
314
|
projectId: issue.projectId,
|
|
314
315
|
linearIssueId: issue.linearIssueId,
|
|
315
316
|
lastGitHubFailureSource: inferred,
|
|
@@ -317,7 +318,7 @@ export class IdleIssueReconciler {
|
|
|
317
318
|
...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
|
|
318
319
|
...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
|
|
319
320
|
});
|
|
320
|
-
const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
|
|
321
|
+
const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
|
|
321
322
|
if (!refreshed)
|
|
322
323
|
return issue;
|
|
323
324
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
|
|
@@ -337,7 +338,7 @@ export class IdleIssueReconciler {
|
|
|
337
338
|
const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
|
|
338
339
|
const failureSignature = issue.lastGitHubFailureSignature
|
|
339
340
|
?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
|
|
340
|
-
this.db.upsertIssue({
|
|
341
|
+
this.db.issues.upsertIssue({
|
|
341
342
|
projectId: issue.projectId,
|
|
342
343
|
linearIssueId: issue.linearIssueId,
|
|
343
344
|
lastGitHubFailureSource: "queue_eviction",
|
|
@@ -345,7 +346,7 @@ export class IdleIssueReconciler {
|
|
|
345
346
|
...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
|
|
346
347
|
...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
|
|
347
348
|
});
|
|
348
|
-
const refreshed = this.db.getIssue(issue.projectId, issue.linearIssueId);
|
|
349
|
+
const refreshed = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
|
|
349
350
|
if (!refreshed)
|
|
350
351
|
return issue;
|
|
351
352
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
|
|
@@ -410,7 +411,7 @@ export class IdleIssueReconciler {
|
|
|
410
411
|
const previousHeadSha = issue.prHeadSha;
|
|
411
412
|
const gateCheckNames = getGateCheckNames(project);
|
|
412
413
|
const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
|
|
413
|
-
this.db.upsertIssue({
|
|
414
|
+
this.db.issues.upsertIssue({
|
|
414
415
|
projectId: issue.projectId,
|
|
415
416
|
linearIssueId: issue.linearIssueId,
|
|
416
417
|
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
@@ -433,13 +434,13 @@ export class IdleIssueReconciler {
|
|
|
433
434
|
: {}),
|
|
434
435
|
});
|
|
435
436
|
if (pr.state === "MERGED") {
|
|
436
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
437
|
+
this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
437
438
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
438
439
|
return;
|
|
439
440
|
}
|
|
440
441
|
if (pr.state === "CLOSED") {
|
|
441
442
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed, re-delegating for implementation");
|
|
442
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
|
|
443
|
+
this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "closed" });
|
|
443
444
|
this.advanceIdleIssue(issue, "delegated", {
|
|
444
445
|
pendingRunType: "implementation",
|
|
445
446
|
clearFailureProvenance: true,
|
|
@@ -481,7 +482,7 @@ export class IdleIssueReconciler {
|
|
|
481
482
|
}
|
|
482
483
|
const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved" || pr.reviewDecision === "APPROVED";
|
|
483
484
|
const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
|
|
484
|
-
const refreshedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
485
|
+
const refreshedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
485
486
|
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
486
487
|
prNumber: refreshedIssue.prNumber,
|
|
487
488
|
prState: refreshedIssue.prState,
|
|
@@ -493,6 +494,16 @@ export class IdleIssueReconciler {
|
|
|
493
494
|
});
|
|
494
495
|
if ((issue.factoryState === "escalated" || issue.factoryState === "failed")
|
|
495
496
|
&& (reactiveIntent?.runType === "review_fix" || reactiveIntent?.runType === "branch_upkeep")) {
|
|
497
|
+
if (issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
|
|
498
|
+
this.logger.debug({
|
|
499
|
+
issueKey: issue.issueKey,
|
|
500
|
+
prNumber: issue.prNumber,
|
|
501
|
+
from: issue.factoryState,
|
|
502
|
+
runType: reactiveIntent.runType,
|
|
503
|
+
reviewFixAttempts: issue.reviewFixAttempts,
|
|
504
|
+
}, "Reconciliation: leaving terminal requested-changes issue escalated because the repair budget is exhausted");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
496
507
|
const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
|
|
497
508
|
? buildBranchUpkeepContext(issue.prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
|
|
498
509
|
: undefined;
|
|
@@ -549,7 +560,7 @@ export class IdleIssueReconciler {
|
|
|
549
560
|
return;
|
|
550
561
|
}
|
|
551
562
|
if (isReviewDecisionApproved(pr.reviewDecision)) {
|
|
552
|
-
this.db.upsertIssue({
|
|
563
|
+
this.db.issues.upsertIssue({
|
|
553
564
|
projectId: issue.projectId,
|
|
554
565
|
linearIssueId: issue.linearIssueId,
|
|
555
566
|
prReviewState: "approved",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
2
|
+
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
|
+
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
4
|
+
function isRequestedChangesRunType(runType) {
|
|
5
|
+
return runType === "review_fix" || runType === "branch_upkeep";
|
|
6
|
+
}
|
|
7
|
+
function resolveRetryRunType(runType, context) {
|
|
8
|
+
if (runType === "branch_upkeep") {
|
|
9
|
+
return "branch_upkeep";
|
|
10
|
+
}
|
|
11
|
+
return context?.reviewFixMode === "branch_upkeep" || context?.branchUpkeepRequired === true
|
|
12
|
+
? "branch_upkeep"
|
|
13
|
+
: "review_fix";
|
|
14
|
+
}
|
|
15
|
+
function resolvePostRunState(issue) {
|
|
16
|
+
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
|
|
17
|
+
if (issue.prState === "merged")
|
|
18
|
+
return "done";
|
|
19
|
+
if (issue.prReviewState === "approved")
|
|
20
|
+
return "awaiting_queue";
|
|
21
|
+
return "pr_open";
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function resolveRecoverablePostRunState(issue) {
|
|
26
|
+
if (!issue.prNumber) {
|
|
27
|
+
return resolvePostRunState(issue);
|
|
28
|
+
}
|
|
29
|
+
if (issue.prState === "merged")
|
|
30
|
+
return "done";
|
|
31
|
+
if (issue.prState === "open") {
|
|
32
|
+
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
33
|
+
prNumber: issue.prNumber,
|
|
34
|
+
prState: issue.prState,
|
|
35
|
+
prReviewState: issue.prReviewState,
|
|
36
|
+
prCheckStatus: issue.prCheckStatus,
|
|
37
|
+
latestFailureSource: issue.lastGitHubFailureSource,
|
|
38
|
+
});
|
|
39
|
+
if (reactiveIntent)
|
|
40
|
+
return reactiveIntent.compatibilityFactoryState;
|
|
41
|
+
if (issue.prReviewState === "approved")
|
|
42
|
+
return "awaiting_queue";
|
|
43
|
+
return "pr_open";
|
|
44
|
+
}
|
|
45
|
+
return resolvePostRunState(issue);
|
|
46
|
+
}
|
|
47
|
+
export class InterruptedRunRecovery {
|
|
48
|
+
db;
|
|
49
|
+
logger;
|
|
50
|
+
linearSync;
|
|
51
|
+
withHeldLease;
|
|
52
|
+
releaseLease;
|
|
53
|
+
failRunAndClear;
|
|
54
|
+
restoreIdleWorktree;
|
|
55
|
+
completionPolicy;
|
|
56
|
+
enqueueIssue;
|
|
57
|
+
feed;
|
|
58
|
+
constructor(db, logger, linearSync, withHeldLease, releaseLease, failRunAndClear, restoreIdleWorktree, completionPolicy, enqueueIssue, feed) {
|
|
59
|
+
this.db = db;
|
|
60
|
+
this.logger = logger;
|
|
61
|
+
this.linearSync = linearSync;
|
|
62
|
+
this.withHeldLease = withHeldLease;
|
|
63
|
+
this.releaseLease = releaseLease;
|
|
64
|
+
this.failRunAndClear = failRunAndClear;
|
|
65
|
+
this.restoreIdleWorktree = restoreIdleWorktree;
|
|
66
|
+
this.completionPolicy = completionPolicy;
|
|
67
|
+
this.enqueueIssue = enqueueIssue;
|
|
68
|
+
this.feed = feed;
|
|
69
|
+
}
|
|
70
|
+
async handle(run, issue) {
|
|
71
|
+
this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn - marking as failed");
|
|
72
|
+
const repairedCounters = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
73
|
+
if (run.runType === "ci_repair" && issue.ciRepairAttempts > 0) {
|
|
74
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
75
|
+
projectId: issue.projectId,
|
|
76
|
+
linearIssueId: issue.linearIssueId,
|
|
77
|
+
ciRepairAttempts: issue.ciRepairAttempts - 1,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else if (run.runType === "queue_repair" && issue.queueRepairAttempts > 0) {
|
|
81
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
82
|
+
projectId: issue.projectId,
|
|
83
|
+
linearIssueId: issue.linearIssueId,
|
|
84
|
+
queueRepairAttempts: issue.queueRepairAttempts - 1,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (run.runType === "ci_repair" || run.runType === "queue_repair") {
|
|
88
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
89
|
+
projectId: issue.projectId,
|
|
90
|
+
linearIssueId: issue.linearIssueId,
|
|
91
|
+
lastAttemptedFailureHeadSha: null,
|
|
92
|
+
lastAttemptedFailureSignature: null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
if (!repairedCounters) {
|
|
98
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping interrupted-run recovery after losing issue-session lease");
|
|
99
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (isRequestedChangesRunType(run.runType)) {
|
|
103
|
+
await this.handleInterruptedRequestedChangesRun(run, issue);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const recoveredState = resolveRecoverablePostRunState(this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue);
|
|
107
|
+
this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
|
|
108
|
+
await this.restoreIdleWorktree(issue);
|
|
109
|
+
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
110
|
+
if (recoveredState) {
|
|
111
|
+
this.feed?.publish({
|
|
112
|
+
level: "info",
|
|
113
|
+
kind: "stage",
|
|
114
|
+
issueKey: issue.issueKey,
|
|
115
|
+
projectId: run.projectId,
|
|
116
|
+
stage: recoveredState,
|
|
117
|
+
status: "reconciled",
|
|
118
|
+
summary: `Interrupted ${run.runType} recovered -> ${recoveredState}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
|
|
123
|
+
}
|
|
124
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
125
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
126
|
+
}
|
|
127
|
+
async handleInterruptedRequestedChangesRun(run, issue) {
|
|
128
|
+
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
129
|
+
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
|
130
|
+
const retryContext = await this.completionPolicy.resolveRequestedChangesWakeContext(refreshedIssue, run.runType, run.runType === "branch_upkeep"
|
|
131
|
+
? {
|
|
132
|
+
branchUpkeepRequired: true,
|
|
133
|
+
reviewFixMode: "branch_upkeep",
|
|
134
|
+
wakeReason: "branch_upkeep",
|
|
135
|
+
}
|
|
136
|
+
: undefined);
|
|
137
|
+
const retryRunType = resolveRetryRunType(run.runType, retryContext);
|
|
138
|
+
const recoveredState = resolveRecoverablePostRunState(refreshedIssue) ?? "failed";
|
|
139
|
+
const interruptedMessage = "Requested-changes run was interrupted before PatchRelay could verify that a new PR head was published";
|
|
140
|
+
this.failRunAndClear(run, interruptedMessage, recoveredState);
|
|
141
|
+
await this.restoreIdleWorktree(issue);
|
|
142
|
+
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
143
|
+
if (recoveredState === "changes_requested") {
|
|
144
|
+
this.db.issues.upsertIssue({
|
|
145
|
+
projectId: run.projectId,
|
|
146
|
+
linearIssueId: run.linearIssueId,
|
|
147
|
+
pendingRunType: retryRunType,
|
|
148
|
+
pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
|
|
149
|
+
});
|
|
150
|
+
this.feed?.publish({
|
|
151
|
+
level: "warn",
|
|
152
|
+
kind: "workflow",
|
|
153
|
+
issueKey: issue.issueKey,
|
|
154
|
+
projectId: run.projectId,
|
|
155
|
+
stage: run.runType,
|
|
156
|
+
status: "retry_queued",
|
|
157
|
+
summary: "Requested-changes run was interrupted; PatchRelay will retry from fresh GitHub truth",
|
|
158
|
+
});
|
|
159
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
this.feed?.publish({
|
|
163
|
+
level: "error",
|
|
164
|
+
kind: "workflow",
|
|
165
|
+
issueKey: issue.issueKey,
|
|
166
|
+
projectId: run.projectId,
|
|
167
|
+
stage: run.runType,
|
|
168
|
+
status: "escalated",
|
|
169
|
+
summary: interruptedMessage,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, interruptedMessage));
|
|
173
|
+
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
174
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -71,7 +71,7 @@ export class IssueQueryService {
|
|
|
71
71
|
const legacy = this.db.getIssueOverview(issueKey);
|
|
72
72
|
if (!legacy)
|
|
73
73
|
return undefined;
|
|
74
|
-
const issueRecord = this.db.getIssueByKey(issueKey);
|
|
74
|
+
const issueRecord = this.db.issues.getIssueByKey(issueKey);
|
|
75
75
|
const activeStatus = await this.runStatusProvider.getActiveRunStatus(issueKey);
|
|
76
76
|
const activeRun = activeStatus?.run ?? legacy.activeRun;
|
|
77
77
|
const latestRun = this.db.runs.getLatestRunForIssue(legacy.issue.projectId, legacy.issue.linearIssueId);
|
|
@@ -123,8 +123,8 @@ export class IssueQueryService {
|
|
|
123
123
|
: {}),
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
-
const issueRecord = this.db.getIssueByKey(issueKey);
|
|
127
|
-
const blockedBy = this.db.listIssueDependencies(session.projectId, session.linearIssueId);
|
|
126
|
+
const issueRecord = this.db.issues.getIssueByKey(issueKey);
|
|
127
|
+
const blockedBy = this.db.issues.listIssueDependencies(session.projectId, session.linearIssueId);
|
|
128
128
|
const unresolvedBlockedBy = blockedBy.filter((entry) => (entry.blockerCurrentLinearStateType !== "completed"
|
|
129
129
|
&& entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done"));
|
|
130
130
|
const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
|
|
@@ -231,7 +231,7 @@ export class IssueQueryService {
|
|
|
231
231
|
const overview = await this.getIssueOverview(issueKey);
|
|
232
232
|
if (!overview)
|
|
233
233
|
return undefined;
|
|
234
|
-
const issueRecord = this.db.getIssueByKey(issueKey);
|
|
234
|
+
const issueRecord = this.db.issues.getIssueByKey(issueKey);
|
|
235
235
|
const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
|
|
236
236
|
const runs = (overview.runs ?? this.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
|
|
237
237
|
run: {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isoNow } from "./db/shared.js";
|
|
2
|
+
import { buildTrackedIssueRecord } from "./tracked-issue-projector.js";
|
|
3
|
+
import { extractLatestAssistantSummary, } from "./issue-session-events.js";
|
|
4
|
+
import { deriveIssueSessionState, deriveIssueSessionWakeReason, } from "./issue-session.js";
|
|
5
|
+
export function syncIssueSessionFromIssue(params) {
|
|
6
|
+
const { connection, issues, issueSessions, runs, issue, options } = params;
|
|
7
|
+
const existing = issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
8
|
+
const latestRun = runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
9
|
+
const latestRunType = options?.lastRunType ?? latestRun?.runType ?? existing?.lastRunType;
|
|
10
|
+
const summaryText = resolveIssueSessionSummary(issue, runs, latestRun, existing?.summaryText, options?.summaryText);
|
|
11
|
+
const activeThreadId = issue.threadId ?? existing?.activeThreadId;
|
|
12
|
+
const threadGeneration = activeThreadId && activeThreadId !== existing?.activeThreadId
|
|
13
|
+
? (existing?.threadGeneration ?? 0) + 1
|
|
14
|
+
: (existing?.threadGeneration ?? (activeThreadId ? 1 : 0));
|
|
15
|
+
const sessionState = deriveIssueSessionState({
|
|
16
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
17
|
+
factoryState: issue.factoryState,
|
|
18
|
+
});
|
|
19
|
+
const tracked = buildTrackedIssueRecord({
|
|
20
|
+
issue,
|
|
21
|
+
session: existing,
|
|
22
|
+
blockedBy: issues.listIssueDependencies(issue.projectId, issue.linearIssueId),
|
|
23
|
+
hasPendingWake: issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) !== undefined,
|
|
24
|
+
latestRun,
|
|
25
|
+
latestEvent: issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { limit: 1 }).at(-1),
|
|
26
|
+
});
|
|
27
|
+
const lastWakeReason = options?.lastWakeReason
|
|
28
|
+
?? deriveIssueSessionWakeReason({
|
|
29
|
+
pendingRunType: issue.pendingRunType,
|
|
30
|
+
factoryState: issue.factoryState,
|
|
31
|
+
prNumber: issue.prNumber,
|
|
32
|
+
prState: issue.prState,
|
|
33
|
+
prReviewState: issue.prReviewState,
|
|
34
|
+
prCheckStatus: issue.prCheckStatus,
|
|
35
|
+
latestFailureSource: issue.lastGitHubFailureSource,
|
|
36
|
+
})
|
|
37
|
+
?? existing?.lastWakeReason;
|
|
38
|
+
const now = isoNow();
|
|
39
|
+
if (existing) {
|
|
40
|
+
connection.prepare(`
|
|
41
|
+
UPDATE issue_sessions SET
|
|
42
|
+
issue_key = ?,
|
|
43
|
+
repo_id = ?,
|
|
44
|
+
branch_name = ?,
|
|
45
|
+
worktree_path = ?,
|
|
46
|
+
pr_number = ?,
|
|
47
|
+
pr_head_sha = ?,
|
|
48
|
+
pr_author_login = ?,
|
|
49
|
+
session_state = ?,
|
|
50
|
+
waiting_reason = ?,
|
|
51
|
+
summary_text = ?,
|
|
52
|
+
active_thread_id = ?,
|
|
53
|
+
thread_generation = ?,
|
|
54
|
+
active_run_id = ?,
|
|
55
|
+
last_run_type = ?,
|
|
56
|
+
last_wake_reason = ?,
|
|
57
|
+
ci_repair_attempts = ?,
|
|
58
|
+
queue_repair_attempts = ?,
|
|
59
|
+
review_fix_attempts = ?,
|
|
60
|
+
updated_at = ?
|
|
61
|
+
WHERE project_id = ? AND linear_issue_id = ?
|
|
62
|
+
`).run(issue.issueKey ?? null, issue.projectId, issue.branchName ?? null, issue.worktreePath ?? null, issue.prNumber ?? null, issue.prHeadSha ?? null, issue.prAuthorLogin ?? null, sessionState, tracked.waitingReason ?? null, summaryText ?? null, activeThreadId ?? null, threadGeneration, issue.activeRunId ?? null, latestRunType ?? null, lastWakeReason ?? null, issue.ciRepairAttempts, issue.queueRepairAttempts, issue.reviewFixAttempts, now, issue.projectId, issue.linearIssueId);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
connection.prepare(`
|
|
66
|
+
INSERT INTO issue_sessions (
|
|
67
|
+
project_id, linear_issue_id, issue_key, repo_id, branch_name, worktree_path,
|
|
68
|
+
pr_number, pr_head_sha, pr_author_login, session_state, waiting_reason, summary_text,
|
|
69
|
+
active_thread_id, thread_generation, active_run_id, last_run_type, last_wake_reason,
|
|
70
|
+
ci_repair_attempts, queue_repair_attempts, review_fix_attempts,
|
|
71
|
+
created_at, updated_at
|
|
72
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
`).run(issue.projectId, issue.linearIssueId, issue.issueKey ?? null, issue.projectId, issue.branchName ?? null, issue.worktreePath ?? null, issue.prNumber ?? null, issue.prHeadSha ?? null, issue.prAuthorLogin ?? null, sessionState, tracked.waitingReason ?? null, summaryText ?? null, activeThreadId ?? null, threadGeneration, issue.activeRunId ?? null, latestRunType ?? null, lastWakeReason ?? null, issue.ciRepairAttempts, issue.queueRepairAttempts, issue.reviewFixAttempts, now, now);
|
|
74
|
+
}
|
|
75
|
+
function resolveIssueSessionSummary(issue, runs, latestRun, existingSummaryText, explicitSummaryText) {
|
|
76
|
+
if (explicitSummaryText?.trim()) {
|
|
77
|
+
return explicitSummaryText;
|
|
78
|
+
}
|
|
79
|
+
const latestSummary = extractLatestAssistantSummary(latestRun);
|
|
80
|
+
if (latestRun && (latestRun.status === "queued" || latestRun.status === "running")) {
|
|
81
|
+
return latestSummary;
|
|
82
|
+
}
|
|
83
|
+
if (shouldKeepPreviousIssueSummary(issue, latestRun)) {
|
|
84
|
+
return findLatestCompletedRunSummary(runs, issue.projectId, issue.linearIssueId)
|
|
85
|
+
?? existingSummaryText
|
|
86
|
+
?? latestSummary;
|
|
87
|
+
}
|
|
88
|
+
return latestSummary ?? existingSummaryText;
|
|
89
|
+
}
|
|
90
|
+
function shouldKeepPreviousIssueSummary(issue, latestRun) {
|
|
91
|
+
if (!latestRun || latestRun.status !== "failed") {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (latestRun.summaryJson || latestRun.reportJson) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return issue.factoryState === "pr_open"
|
|
98
|
+
|| issue.factoryState === "awaiting_queue"
|
|
99
|
+
|| issue.factoryState === "done";
|
|
100
|
+
}
|
|
101
|
+
function findLatestCompletedRunSummary(runs, projectId, linearIssueId) {
|
|
102
|
+
const issueRuns = runs.listRunsForIssue(projectId, linearIssueId);
|
|
103
|
+
for (let index = issueRuns.length - 1; index >= 0; index -= 1) {
|
|
104
|
+
const run = issueRuns[index];
|
|
105
|
+
if (!run || run.status !== "completed") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const summary = extractLatestAssistantSummary(run);
|
|
109
|
+
if (summary?.trim()) {
|
|
110
|
+
return summary;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|