patchrelay 0.37.1 → 0.38.0
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/README.md +47 -9
- package/dist/awaiting-input-reason.js +9 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +59 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/output.js +2 -0
- package/dist/db/issue-session-store.js +0 -14
- package/dist/db/issue-store.js +8 -16
- package/dist/db/migrations.js +6 -13
- package/dist/db.js +1 -3
- package/dist/github-webhook-handler.js +70 -51
- package/dist/github-webhooks.js +4 -0
- package/dist/idle-reconciliation.js +22 -23
- package/dist/issue-overview-query.js +3 -0
- package/dist/issue-session-projector.js +1 -0
- package/dist/issue-session.js +8 -0
- package/dist/linear-session-reporting.js +30 -1
- package/dist/linear-session-sync.js +9 -1
- package/dist/linear-status-comment-sync.js +34 -1
- package/dist/linear-workflow-state-sync.js +2 -2
- package/dist/operator-retry-event.js +15 -12
- package/dist/paused-issue-state.js +24 -0
- package/dist/run-launcher.js +0 -1
- package/dist/run-orchestrator.js +2 -5
- package/dist/run-reconciler.js +10 -0
- package/dist/run-recovery-service.js +1 -10
- package/dist/service-issue-actions.js +5 -0
- package/dist/service-startup-recovery.js +9 -6
- package/dist/service.js +0 -1
- package/dist/tracked-issue-list-query.js +3 -1
- package/dist/tracked-issue-projector.js +3 -0
- package/dist/waiting-reason.js +10 -0
- package/dist/webhooks/agent-session-handler.js +9 -1
- package/dist/webhooks/comment-wake-handler.js +12 -0
- package/dist/webhooks/decision-helpers.js +44 -3
- package/dist/webhooks/dependency-readiness-handler.js +1 -0
- package/dist/webhooks/desired-stage-recorder.js +40 -10
- package/package.json +1 -1
|
@@ -34,7 +34,6 @@ export class GitHubWebhookHandler {
|
|
|
34
34
|
failureContextResolver;
|
|
35
35
|
ciSnapshotResolver;
|
|
36
36
|
fetchImpl;
|
|
37
|
-
patchRelayAuthorLogins = new Set();
|
|
38
37
|
constructor(config, db, linearProvider, enqueueIssue, logger, codex, feed, failureContextResolver = createGitHubFailureContextResolver(), ciSnapshotResolver = createGitHubCiSnapshotResolver(), fetchImpl = fetch) {
|
|
39
38
|
this.config = config;
|
|
40
39
|
this.db = db;
|
|
@@ -46,18 +45,6 @@ export class GitHubWebhookHandler {
|
|
|
46
45
|
this.failureContextResolver = failureContextResolver;
|
|
47
46
|
this.ciSnapshotResolver = ciSnapshotResolver;
|
|
48
47
|
this.fetchImpl = fetchImpl;
|
|
49
|
-
for (const login of resolvePatchRelayAuthorLoginsFromEnv()) {
|
|
50
|
-
this.patchRelayAuthorLogins.add(login);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
setPatchRelayAuthorLogins(logins) {
|
|
54
|
-
this.patchRelayAuthorLogins.clear();
|
|
55
|
-
for (const login of logins) {
|
|
56
|
-
const normalized = normalizeAuthorLogin(login);
|
|
57
|
-
if (normalized) {
|
|
58
|
-
this.patchRelayAuthorLogins.add(normalized);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
48
|
}
|
|
62
49
|
async acceptGitHubWebhook(params) {
|
|
63
50
|
// Deduplicate
|
|
@@ -130,13 +117,17 @@ export class GitHubWebhookHandler {
|
|
|
130
117
|
this.logger.debug({ eventType: params.eventType }, "GitHub webhook: unrecognized event type or action");
|
|
131
118
|
return;
|
|
132
119
|
}
|
|
133
|
-
|
|
134
|
-
|
|
120
|
+
const project = this.config.projects.find((candidate) => candidate.github?.repoFullName === event.repoFullName);
|
|
121
|
+
if (!project) {
|
|
122
|
+
this.logger.debug({ repoFullName: event.repoFullName, triggerEvent: event.triggerEvent }, "GitHub webhook: no configured project for repository");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const resolved = this.resolveIssueForEvent(project, event);
|
|
126
|
+
const issue = resolved?.issue;
|
|
135
127
|
if (!issue) {
|
|
136
|
-
this.logger.debug({ branchName: event.branchName, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching issue
|
|
128
|
+
this.logger.debug({ repoFullName: event.repoFullName, branchName: event.branchName, prNumber: event.prNumber, triggerEvent: event.triggerEvent }, "GitHub webhook: no matching tracked issue");
|
|
137
129
|
return;
|
|
138
130
|
}
|
|
139
|
-
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
140
131
|
const immediateCheckStatus = this.deriveImmediatePrCheckStatus(issue, event, project);
|
|
141
132
|
// Update PR state on the issue
|
|
142
133
|
this.db.issues.upsertIssue({
|
|
@@ -149,6 +140,7 @@ export class GitHubWebhookHandler {
|
|
|
149
140
|
...(event.prAuthorLogin !== undefined ? { prAuthorLogin: event.prAuthorLogin } : {}),
|
|
150
141
|
...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
|
|
151
142
|
...(immediateCheckStatus !== undefined ? { prCheckStatus: immediateCheckStatus } : {}),
|
|
143
|
+
...(resolved.linkedBy === "issue_key" ? { branchName: event.branchName } : {}),
|
|
152
144
|
...(event.reviewState === "changes_requested"
|
|
153
145
|
? { lastBlockingReviewHeadSha: event.reviewCommitId ?? event.headSha ?? null }
|
|
154
146
|
: event.reviewState === "approved"
|
|
@@ -230,10 +222,57 @@ export class GitHubWebhookHandler {
|
|
|
230
222
|
await this.handleTerminalPrEvent(freshIssue, event);
|
|
231
223
|
}
|
|
232
224
|
}
|
|
225
|
+
resolveIssueForEvent(project, event) {
|
|
226
|
+
if (event.prNumber !== undefined) {
|
|
227
|
+
const byPr = this.db.issues.getIssueByPrNumber(event.prNumber);
|
|
228
|
+
if (byPr && byPr.projectId === project.id) {
|
|
229
|
+
return { issue: byPr, linkedBy: "pr" };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const byBranch = this.db.issues.getIssueByBranch(event.branchName);
|
|
233
|
+
if (byBranch && byBranch.projectId === project.id) {
|
|
234
|
+
return { issue: byBranch, linkedBy: "branch" };
|
|
235
|
+
}
|
|
236
|
+
const byIssueKey = this.resolveIssueByExplicitIssueKey(project, event);
|
|
237
|
+
if (byIssueKey) {
|
|
238
|
+
return { issue: byIssueKey, linkedBy: "issue_key" };
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
resolveIssueByExplicitIssueKey(project, event) {
|
|
243
|
+
const candidates = new Set();
|
|
244
|
+
const sources = [event.prTitle, event.prBody, event.branchName];
|
|
245
|
+
for (const prefix of project.issueKeyPrefixes) {
|
|
246
|
+
const normalizedPrefix = prefix.trim();
|
|
247
|
+
if (!normalizedPrefix)
|
|
248
|
+
continue;
|
|
249
|
+
const pattern = new RegExp(`\\b${escapeRegExp(normalizedPrefix)}-\\d+\\b`, "gi");
|
|
250
|
+
for (const source of sources) {
|
|
251
|
+
if (!source)
|
|
252
|
+
continue;
|
|
253
|
+
for (const match of source.matchAll(pattern)) {
|
|
254
|
+
candidates.add(match[0].toUpperCase());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (candidates.size !== 1) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
const [issueKey] = [...candidates];
|
|
262
|
+
if (!issueKey) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
266
|
+
return issue?.projectId === project.id ? issue : undefined;
|
|
267
|
+
}
|
|
233
268
|
resolveFactoryStateForEvent(issue, event, project) {
|
|
234
269
|
if (event.triggerEvent === "pr_closed") {
|
|
235
270
|
return undefined;
|
|
236
271
|
}
|
|
272
|
+
const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
|
|
273
|
+
&& (event.prState === "open" || event.prNumber !== undefined)
|
|
274
|
+
? "pr_open"
|
|
275
|
+
: issue.factoryState;
|
|
237
276
|
if (event.triggerEvent === "check_failed"
|
|
238
277
|
&& this.isQueueEvictionFailure(issue, event, project)
|
|
239
278
|
&& issue.prState === "open"
|
|
@@ -241,10 +280,17 @@ export class GitHubWebhookHandler {
|
|
|
241
280
|
&& !isIssueTerminal(issue)) {
|
|
242
281
|
return "repairing_queue";
|
|
243
282
|
}
|
|
244
|
-
|
|
283
|
+
const resolved = resolveFactoryStateFromGitHub(event.triggerEvent, effectiveCurrentState, {
|
|
245
284
|
prReviewState: issue.prReviewState,
|
|
246
285
|
activeRunId: issue.activeRunId,
|
|
247
286
|
});
|
|
287
|
+
if (resolved !== undefined) {
|
|
288
|
+
return resolved;
|
|
289
|
+
}
|
|
290
|
+
if (effectiveCurrentState !== issue.factoryState) {
|
|
291
|
+
return effectiveCurrentState;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
248
294
|
}
|
|
249
295
|
async updateCiSnapshot(issue, event, project) {
|
|
250
296
|
if (event.triggerEvent === "pr_merged") {
|
|
@@ -327,15 +373,15 @@ export class GitHubWebhookHandler {
|
|
|
327
373
|
// merge_group_failed after pr_merged) must not resurrect done issues.
|
|
328
374
|
if (isIssueTerminal(issue))
|
|
329
375
|
return;
|
|
330
|
-
if (!
|
|
376
|
+
if (!issue.delegatedToPatchRelay) {
|
|
331
377
|
this.feed?.publish({
|
|
332
378
|
level: "info",
|
|
333
379
|
kind: "github",
|
|
334
380
|
issueKey: issue.issueKey,
|
|
335
381
|
projectId: issue.projectId,
|
|
336
382
|
stage: issue.factoryState,
|
|
337
|
-
status: "
|
|
338
|
-
summary: `Ignored ${event.triggerEvent}
|
|
383
|
+
status: "ignored_undelegated",
|
|
384
|
+
summary: `Ignored ${event.triggerEvent} because the issue is undelegated`,
|
|
339
385
|
});
|
|
340
386
|
return;
|
|
341
387
|
}
|
|
@@ -372,7 +418,6 @@ export class GitHubWebhookHandler {
|
|
|
372
418
|
}),
|
|
373
419
|
dedupeKey: failureContext.failureSignature,
|
|
374
420
|
});
|
|
375
|
-
this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
376
421
|
const queuedRunType = hadPendingWake
|
|
377
422
|
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
378
423
|
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
@@ -430,7 +475,6 @@ export class GitHubWebhookHandler {
|
|
|
430
475
|
}),
|
|
431
476
|
dedupeKey: failureContext.failureSignature,
|
|
432
477
|
});
|
|
433
|
-
this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
434
478
|
const queuedRunType = hadPendingWake
|
|
435
479
|
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
436
480
|
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
@@ -476,7 +520,6 @@ export class GitHubWebhookHandler {
|
|
|
476
520
|
event.reviewerName ?? "unknown-reviewer",
|
|
477
521
|
].join("::"),
|
|
478
522
|
});
|
|
479
|
-
this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
480
523
|
const queuedRunType = hadPendingWake
|
|
481
524
|
? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId)
|
|
482
525
|
: this.enqueuePendingSessionWake(issue.projectId, issue.linearIssueId);
|
|
@@ -554,7 +597,6 @@ export class GitHubWebhookHandler {
|
|
|
554
597
|
eventType: "delegated",
|
|
555
598
|
dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
|
|
556
599
|
});
|
|
557
|
-
this.db.issueSessions.setBranchOwnerRespectingActiveLease(issue.projectId, issue.linearIssueId, "patchrelay");
|
|
558
600
|
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
559
601
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
560
602
|
}
|
|
@@ -709,7 +751,7 @@ export class GitHubWebhookHandler {
|
|
|
709
751
|
if (!signature)
|
|
710
752
|
return false;
|
|
711
753
|
const pendingWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
|
|
712
|
-
if (pendingWake?.runType === runType) {
|
|
754
|
+
if (pendingWake?.runType === runType && pendingWake.eventIds.length > 0) {
|
|
713
755
|
const existing = pendingWake.context;
|
|
714
756
|
if (existing?.failureSignature === signature
|
|
715
757
|
&& (headSha === undefined || existing.failureHeadSha === headSha || existing.headSha === headSha)) {
|
|
@@ -945,8 +987,6 @@ export class GitHubWebhookHandler {
|
|
|
945
987
|
const issue = this.db.issues.getIssueByPrNumber(prNumber);
|
|
946
988
|
if (!issue)
|
|
947
989
|
return;
|
|
948
|
-
if (!this.isPatchRelayOwnedPr(issue))
|
|
949
|
-
return;
|
|
950
990
|
this.feed?.publish({
|
|
951
991
|
level: "info",
|
|
952
992
|
kind: "comment",
|
|
@@ -1009,30 +1049,9 @@ export class GitHubWebhookHandler {
|
|
|
1009
1049
|
this.enqueueIssue(projectId, issueId);
|
|
1010
1050
|
return wake.runType;
|
|
1011
1051
|
}
|
|
1012
|
-
isPatchRelayOwnedPr(issue) {
|
|
1013
|
-
const author = normalizeAuthorLogin(issue.prAuthorLogin);
|
|
1014
|
-
if (author) {
|
|
1015
|
-
if (this.patchRelayAuthorLogins.size > 0) {
|
|
1016
|
-
return this.patchRelayAuthorLogins.has(author);
|
|
1017
|
-
}
|
|
1018
|
-
return author.includes("patchrelay");
|
|
1019
|
-
}
|
|
1020
|
-
// Transitional fallback for rows written before author tracking existed.
|
|
1021
|
-
return issue.prNumber !== undefined && issue.branchOwner === "patchrelay";
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
function normalizeAuthorLogin(login) {
|
|
1025
|
-
const normalized = login?.trim().toLowerCase();
|
|
1026
|
-
return normalized ? normalized : undefined;
|
|
1027
1052
|
}
|
|
1028
|
-
function
|
|
1029
|
-
return [
|
|
1030
|
-
process.env.PATCHRELAY_GITHUB_BOT_LOGIN,
|
|
1031
|
-
process.env.PATCHRELAY_GITHUB_BOT_NAME,
|
|
1032
|
-
]
|
|
1033
|
-
.flatMap((value) => (value ?? "").split(","))
|
|
1034
|
-
.map((value) => normalizeAuthorLogin(value))
|
|
1035
|
-
.filter((value) => Boolean(value));
|
|
1053
|
+
function escapeRegExp(value) {
|
|
1054
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1036
1055
|
}
|
|
1037
1056
|
function buildGitHubReviewUrl(repoFullName, prNumber, reviewId) {
|
|
1038
1057
|
if (!repoFullName || prNumber === undefined || reviewId === undefined) {
|
package/dist/github-webhooks.js
CHANGED
|
@@ -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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 (
|
|
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 (
|
|
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,
|
|
@@ -138,6 +138,7 @@ export class IssueOverviewQuery {
|
|
|
138
138
|
const liveThread = await this.readLiveThread(activeRun);
|
|
139
139
|
const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
|
|
140
140
|
const waitingReason = session.waitingReason ?? derivePatchRelayWaitingReason({
|
|
141
|
+
delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
|
|
141
142
|
...(activeRun ? { activeRunType: activeRun.runType } : {}),
|
|
142
143
|
blockedByKeys,
|
|
143
144
|
factoryState: issueRecord?.factoryState ?? "delegated",
|
|
@@ -154,6 +155,7 @@ export class IssueOverviewQuery {
|
|
|
154
155
|
id: issueRecord?.id ?? session.id,
|
|
155
156
|
projectId: session.projectId,
|
|
156
157
|
linearIssueId: session.linearIssueId,
|
|
158
|
+
delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay ?? true,
|
|
157
159
|
...(session.issueKey ? { issueKey: session.issueKey } : {}),
|
|
158
160
|
...(issueRecord?.title ? { title: issueRecord.title } : {}),
|
|
159
161
|
...(issueRecord?.url ? { issueUrl: issueRecord.url } : {}),
|
|
@@ -169,6 +171,7 @@ export class IssueOverviewQuery {
|
|
|
169
171
|
readyForExecution: isIssueSessionReadyForExecution({
|
|
170
172
|
sessionState: session.sessionState,
|
|
171
173
|
factoryState: issueRecord?.factoryState ?? "delegated",
|
|
174
|
+
delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
|
|
172
175
|
...(activeRun ? { activeRunId: activeRun.id } : {}),
|
|
173
176
|
blockedByCount: unresolvedBlockedBy.length,
|
|
174
177
|
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,
|
package/dist/issue-session.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
|
@@ -9,7 +9,7 @@ function parseObjectJson(value) {
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
export function buildOperatorRetryEvent(issue, runType) {
|
|
12
|
+
export function buildOperatorRetryEvent(issue, runType, source = "operator_retry") {
|
|
13
13
|
if (runType === "queue_repair") {
|
|
14
14
|
const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
|
|
15
15
|
const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
|
|
@@ -18,9 +18,9 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
18
18
|
eventJson: JSON.stringify({
|
|
19
19
|
...(queueIncident ?? {}),
|
|
20
20
|
...(failureContext ?? {}),
|
|
21
|
-
source
|
|
21
|
+
source,
|
|
22
22
|
}),
|
|
23
|
-
dedupeKey:
|
|
23
|
+
dedupeKey: `${source}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
if (runType === "ci_repair") {
|
|
@@ -29,9 +29,9 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
29
29
|
eventType: "settled_red_ci",
|
|
30
30
|
eventJson: JSON.stringify({
|
|
31
31
|
...(failureContext ?? {}),
|
|
32
|
-
source
|
|
32
|
+
source,
|
|
33
33
|
}),
|
|
34
|
-
dedupeKey:
|
|
34
|
+
dedupeKey: `${source}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
@@ -39,20 +39,23 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
39
39
|
eventType: "review_changes_requested",
|
|
40
40
|
eventJson: JSON.stringify({
|
|
41
41
|
reviewBody: runType === "branch_upkeep"
|
|
42
|
-
?
|
|
43
|
-
:
|
|
42
|
+
? `${humanizeSource(source)} requested retry of branch upkeep after requested changes.`
|
|
43
|
+
: `${humanizeSource(source)} requested retry of review-fix work.`,
|
|
44
44
|
...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
|
|
45
|
-
source
|
|
45
|
+
source,
|
|
46
46
|
}),
|
|
47
|
-
dedupeKey:
|
|
47
|
+
dedupeKey: `${source}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
return {
|
|
51
51
|
eventType: "delegated",
|
|
52
52
|
eventJson: JSON.stringify({
|
|
53
|
-
promptContext:
|
|
54
|
-
source
|
|
53
|
+
promptContext: `${humanizeSource(source)} requested PatchRelay work resume.`,
|
|
54
|
+
source,
|
|
55
55
|
}),
|
|
56
|
-
dedupeKey:
|
|
56
|
+
dedupeKey: `${source}:implementation:${issue.linearIssueId}`,
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
function humanizeSource(source) {
|
|
60
|
+
return source.replaceAll("_", " ");
|
|
61
|
+
}
|