patchrelay 0.68.0 → 0.68.2
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/db/issue-store.js +29 -342
- package/dist/db/issue-upsert-columns.js +119 -0
- package/dist/db.js +8 -0
- package/dist/github-pr-comment-handler.js +4 -9
- package/dist/github-webhook-handler.js +12 -7
- package/dist/github-webhook-reactive-run.js +10 -38
- package/dist/github-webhook-stack-coordination.js +5 -2
- package/dist/github-webhook-terminal-handler.js +3 -8
- package/dist/idle-reconciliation-helpers.js +100 -0
- package/dist/idle-reconciliation.js +32 -114
- package/dist/main-branch-health-monitor.js +5 -15
- package/dist/no-pr-completion-check.js +1 -3
- package/dist/orchestration-parent-wake.js +3 -14
- package/dist/queue-health-monitor.js +1 -6
- package/dist/run-finalizer.js +29 -35
- package/dist/run-orchestrator.js +58 -14
- package/dist/service.js +15 -3
- package/dist/wake-dispatcher.js +121 -0
- package/dist/webhook-handler.js +19 -17
- package/dist/webhooks/agent-session-handler.js +4 -8
- package/dist/webhooks/comment-wake-handler.js +8 -24
- package/dist/webhooks/delegation-truth.js +52 -0
- package/dist/webhooks/dependency-readiness-handler.js +4 -4
- package/dist/webhooks/desired-stage-recorder.js +14 -121
- package/dist/webhooks/issue-dependency-sync.js +45 -0
- package/dist/webhooks/linked-pr-adoption.js +41 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { isIssueTerminal } from "./pr-state.js";
|
|
|
4
4
|
import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
|
|
5
5
|
import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
|
|
6
6
|
export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
7
|
-
const { issue, event, project, logger, feed,
|
|
7
|
+
const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
|
|
8
8
|
if (isIssueTerminal(issue))
|
|
9
9
|
return;
|
|
10
10
|
if (!issue.delegatedToPatchRelay) {
|
|
@@ -27,7 +27,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
27
27
|
db,
|
|
28
28
|
logger,
|
|
29
29
|
feed,
|
|
30
|
-
|
|
30
|
+
wakeDispatcher,
|
|
31
31
|
issue,
|
|
32
32
|
event,
|
|
33
33
|
project,
|
|
@@ -40,7 +40,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
40
40
|
db,
|
|
41
41
|
logger,
|
|
42
42
|
feed,
|
|
43
|
-
|
|
43
|
+
wakeDispatcher,
|
|
44
44
|
issue,
|
|
45
45
|
event,
|
|
46
46
|
fetchImpl,
|
|
@@ -48,7 +48,7 @@ export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
async function handleCheckFailedEvent(params) {
|
|
51
|
-
const { db, logger, feed,
|
|
51
|
+
const { db, logger, feed, wakeDispatcher, issue, event, project, failureContextResolver } = params;
|
|
52
52
|
// Plan §4.3: while In Deploy (`awaiting_queue`), branch CI is metadata
|
|
53
53
|
// only — the lander owns admission, and its spec CI on the integration
|
|
54
54
|
// tree is the gate. Queue eviction failures still flow through (they're
|
|
@@ -71,20 +71,14 @@ async function handleCheckFailedEvent(params) {
|
|
|
71
71
|
if (hasDuplicatePendingReactiveRun(db, feed, issue, "queue_repair", failureContext)) {
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
-
const
|
|
75
|
-
db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
76
|
-
projectId: issue.projectId,
|
|
77
|
-
linearIssueId: issue.linearIssueId,
|
|
74
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
78
75
|
eventType: "merge_steward_incident",
|
|
79
76
|
eventJson: JSON.stringify({
|
|
80
77
|
...queueRepairContext,
|
|
81
78
|
...failureContext,
|
|
82
79
|
}),
|
|
83
|
-
dedupeKey: failureContext.failureSignature,
|
|
80
|
+
...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
|
|
84
81
|
});
|
|
85
|
-
const queuedRunType = hadPendingWake
|
|
86
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
87
|
-
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
88
82
|
logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
|
|
89
83
|
feed?.publish({
|
|
90
84
|
level: "warn",
|
|
@@ -120,7 +114,6 @@ async function handleCheckFailedEvent(params) {
|
|
|
120
114
|
if (hasDuplicatePendingReactiveRun(db, feed, issue, "ci_repair", failureContext)) {
|
|
121
115
|
return;
|
|
122
116
|
}
|
|
123
|
-
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
124
117
|
const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
|
|
125
118
|
db.issues.upsertIssue({
|
|
126
119
|
projectId: issue.projectId,
|
|
@@ -134,20 +127,15 @@ async function handleCheckFailedEvent(params) {
|
|
|
134
127
|
lastGitHubFailureAt: new Date().toISOString(),
|
|
135
128
|
lastQueueIncidentJson: null,
|
|
136
129
|
});
|
|
137
|
-
|
|
138
|
-
projectId: issue.projectId,
|
|
139
|
-
linearIssueId: issue.linearIssueId,
|
|
130
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
140
131
|
eventType: "settled_red_ci",
|
|
141
132
|
eventJson: JSON.stringify({
|
|
142
133
|
...failureContext,
|
|
143
134
|
checkClass: resolveGitHubCheckClass(failureContext.checkName ?? event.checkName, project),
|
|
144
135
|
...(snapshot ? { ciSnapshot: snapshot } : {}),
|
|
145
136
|
}),
|
|
146
|
-
dedupeKey: failureContext.failureSignature,
|
|
137
|
+
...(failureContext.failureSignature ? { dedupeKey: failureContext.failureSignature } : {}),
|
|
147
138
|
});
|
|
148
|
-
const queuedRunType = hadPendingWake
|
|
149
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
150
|
-
: enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId);
|
|
151
139
|
logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
|
|
152
140
|
feed?.publish({
|
|
153
141
|
level: "warn",
|
|
@@ -161,8 +149,7 @@ async function handleCheckFailedEvent(params) {
|
|
|
161
149
|
});
|
|
162
150
|
}
|
|
163
151
|
async function handleRequestedChangesEvent(params) {
|
|
164
|
-
const {
|
|
165
|
-
const hadPendingWake = db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId);
|
|
152
|
+
const { logger, feed, wakeDispatcher, issue, event, fetchImpl } = params;
|
|
166
153
|
const reviewComments = await fetchReviewCommentsForEvent(event, fetchImpl).catch((error) => {
|
|
167
154
|
logger.warn({
|
|
168
155
|
issueKey: issue.issueKey,
|
|
@@ -172,9 +159,7 @@ async function handleRequestedChangesEvent(params) {
|
|
|
172
159
|
}, "Failed to fetch inline review comments for requested-changes event");
|
|
173
160
|
return undefined;
|
|
174
161
|
});
|
|
175
|
-
|
|
176
|
-
projectId: issue.projectId,
|
|
177
|
-
linearIssueId: issue.linearIssueId,
|
|
162
|
+
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
178
163
|
eventType: "review_changes_requested",
|
|
179
164
|
eventJson: JSON.stringify({
|
|
180
165
|
reviewBody: event.reviewBody,
|
|
@@ -190,11 +175,6 @@ async function handleRequestedChangesEvent(params) {
|
|
|
190
175
|
event.reviewerName ?? "unknown-reviewer",
|
|
191
176
|
].join("::"),
|
|
192
177
|
});
|
|
193
|
-
const queuedRunType = hadPendingWake
|
|
194
|
-
? db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType
|
|
195
|
-
: issue.activeRunId === undefined
|
|
196
|
-
? enqueuePendingSessionWake(db, enqueueIssue, issue.projectId, issue.linearIssueId)
|
|
197
|
-
: db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)?.runType;
|
|
198
178
|
logger.info({
|
|
199
179
|
issueKey: issue.issueKey,
|
|
200
180
|
reviewerName: event.reviewerName,
|
|
@@ -254,14 +234,6 @@ function hasDuplicatePendingReactiveRun(db, feed, issue, runType, failureContext
|
|
|
254
234
|
}
|
|
255
235
|
return false;
|
|
256
236
|
}
|
|
257
|
-
function enqueuePendingSessionWake(db, enqueueIssue, projectId, issueId) {
|
|
258
|
-
const wake = db.issueSessions.peekIssueSessionWake(projectId, issueId);
|
|
259
|
-
if (!wake) {
|
|
260
|
-
return undefined;
|
|
261
|
-
}
|
|
262
|
-
enqueueIssue(projectId, issueId);
|
|
263
|
-
return wake.runType;
|
|
264
|
-
}
|
|
265
237
|
async function fetchReviewCommentsForEvent(event, fetchImpl) {
|
|
266
238
|
if (event.triggerEvent !== "review_changes_requested") {
|
|
267
239
|
return undefined;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// matching child and enqueues a `branch_upkeep` run to rebase the
|
|
5
5
|
// child onto the new parent head.
|
|
6
6
|
export function maybeFanChildRebaseWakes(params) {
|
|
7
|
-
const { db, logger, feed,
|
|
7
|
+
const { db, logger, feed, wakeDispatcher, event } = params;
|
|
8
8
|
if (event.triggerEvent !== "pr_synchronize")
|
|
9
9
|
return;
|
|
10
10
|
if (!event.branchName)
|
|
@@ -24,7 +24,10 @@ export function maybeFanChildRebaseWakes(params) {
|
|
|
24
24
|
linearIssueId: child.linearIssueId,
|
|
25
25
|
pendingRunType: "branch_upkeep",
|
|
26
26
|
});
|
|
27
|
-
|
|
27
|
+
// The pending_run_type field above isn't an event, so we still need
|
|
28
|
+
// an explicit dispatch call. dispatchIfWakePending will pick up the
|
|
29
|
+
// wake derived from the legacy pendingRunType column.
|
|
30
|
+
wakeDispatcher.dispatchIfWakePending(child.projectId, child.linearIssueId);
|
|
28
31
|
logger.info({
|
|
29
32
|
parentBranch: event.branchName,
|
|
30
33
|
parentHeadSha: event.headSha,
|
|
@@ -3,7 +3,7 @@ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
|
3
3
|
import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
|
|
4
4
|
import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
|
|
5
5
|
export async function handleGitHubTerminalPrEvent(params) {
|
|
6
|
-
const { db, linearProvider,
|
|
6
|
+
const { db, linearProvider, wakeDispatcher, logger, codex, issue, event, config } = params;
|
|
7
7
|
const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
|
|
8
8
|
db.issueSessions.appendIssueSessionEvent({
|
|
9
9
|
projectId: issue.projectId,
|
|
@@ -56,22 +56,17 @@ export async function handleGitHubTerminalPrEvent(params) {
|
|
|
56
56
|
db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
57
57
|
const updatedIssue = db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
58
58
|
if (event.triggerEvent === "pr_closed" && resolveClosedPrDisposition(issue) === "redelegate") {
|
|
59
|
-
|
|
60
|
-
projectId: issue.projectId,
|
|
61
|
-
linearIssueId: issue.linearIssueId,
|
|
59
|
+
wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
62
60
|
eventType: "delegated",
|
|
63
61
|
dedupeKey: `github_pr_closed:implementation:${issue.linearIssueId}`,
|
|
64
62
|
});
|
|
65
|
-
if (db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
66
|
-
enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
67
|
-
}
|
|
68
63
|
}
|
|
69
64
|
if (event.triggerEvent === "pr_merged") {
|
|
70
65
|
wakeOrchestrationParentsForChildEvent({
|
|
71
66
|
db,
|
|
72
67
|
child: updatedIssue,
|
|
73
68
|
eventType: "child_delivered",
|
|
74
|
-
|
|
69
|
+
wakeDispatcher,
|
|
75
70
|
});
|
|
76
71
|
await completeLinearIssueAfterMerge(params, updatedIssue);
|
|
77
72
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
2
|
+
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
3
|
+
export function isFailingCheckStatus(status) {
|
|
4
|
+
return status === "failed" || status === "failure";
|
|
5
|
+
}
|
|
6
|
+
export function isReviewDecisionApproved(value) {
|
|
7
|
+
return value?.trim().toUpperCase() === "APPROVED";
|
|
8
|
+
}
|
|
9
|
+
export function isReviewDecisionChangesRequested(value) {
|
|
10
|
+
return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
|
|
11
|
+
}
|
|
12
|
+
export function isReviewDecisionReviewRequired(value) {
|
|
13
|
+
return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
|
|
14
|
+
}
|
|
15
|
+
export function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
|
|
16
|
+
const promptContext = [
|
|
17
|
+
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
|
|
18
|
+
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
19
|
+
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
20
|
+
].join(" ");
|
|
21
|
+
return {
|
|
22
|
+
branchUpkeepRequired: true,
|
|
23
|
+
reviewFixMode: "branch_upkeep",
|
|
24
|
+
wakeReason: "branch_upkeep",
|
|
25
|
+
promptContext,
|
|
26
|
+
...(mergeStateStatus ? { mergeStateStatus } : {}),
|
|
27
|
+
...(headSha ? { failingHeadSha: headSha } : {}),
|
|
28
|
+
baseBranch,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function hasCompletedReviewQuillVerdict(entries) {
|
|
32
|
+
return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
|
|
33
|
+
&& entry.name === "review-quill/verdict"
|
|
34
|
+
&& entry.status === "COMPLETED");
|
|
35
|
+
}
|
|
36
|
+
export function getGateCheckNames(project) {
|
|
37
|
+
const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
38
|
+
return configured.length > 0 ? configured : ["verify"];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A repair attempt is "duplicate" when we have already tried to repair the
|
|
42
|
+
* exact same failure (same signature, same head SHA) AND no newer failure
|
|
43
|
+
* has been observed since that attempt was recorded. For queue evictions
|
|
44
|
+
* the PR head doesn't advance between attempts, so we additionally compare
|
|
45
|
+
* the timestamps: a fresh incident after `main` advances looks identical
|
|
46
|
+
* to a stale one without the timestamp check.
|
|
47
|
+
*/
|
|
48
|
+
export function isDuplicateRepairAttempt(issue, context) {
|
|
49
|
+
const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
|
|
50
|
+
const headSha = typeof context?.failureHeadSha === "string"
|
|
51
|
+
? context.failureHeadSha
|
|
52
|
+
: typeof context?.headSha === "string" ? context.headSha : undefined;
|
|
53
|
+
if (!signature)
|
|
54
|
+
return false;
|
|
55
|
+
if (issue.lastAttemptedFailureSignature !== signature)
|
|
56
|
+
return false;
|
|
57
|
+
if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
|
|
58
|
+
return false;
|
|
59
|
+
if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
|
|
60
|
+
&& issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
export function buildFailureContext(issue) {
|
|
66
|
+
const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
|
|
67
|
+
const queueRepairContext = issue.lastQueueIncidentJson
|
|
68
|
+
? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
|
|
69
|
+
: undefined;
|
|
70
|
+
if (!queueRepairContext
|
|
71
|
+
&& !issue.lastGitHubFailureSource
|
|
72
|
+
&& !issue.lastGitHubFailureHeadSha
|
|
73
|
+
&& !issue.lastGitHubFailureSignature
|
|
74
|
+
&& !issue.lastGitHubFailureCheckName
|
|
75
|
+
&& !issue.lastGitHubFailureCheckUrl
|
|
76
|
+
&& !storedFailureContext) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
|
|
81
|
+
...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
|
|
82
|
+
...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
|
|
83
|
+
...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
|
|
84
|
+
...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
85
|
+
...(storedFailureContext ? storedFailureContext : {}),
|
|
86
|
+
...(queueRepairContext ? queueRepairContext : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function hasFailureProvenance(issue) {
|
|
90
|
+
return Boolean(issue.lastGitHubFailureSource
|
|
91
|
+
|| issue.lastGitHubFailureHeadSha
|
|
92
|
+
|| issue.lastGitHubFailureSignature
|
|
93
|
+
|| issue.lastGitHubFailureCheckName
|
|
94
|
+
|| issue.lastGitHubFailureCheckUrl
|
|
95
|
+
|| issue.lastGitHubFailureContextJson
|
|
96
|
+
|| issue.lastGitHubFailureAt
|
|
97
|
+
|| issue.lastQueueIncidentJson
|
|
98
|
+
|| issue.lastAttemptedFailureHeadSha
|
|
99
|
+
|| issue.lastAttemptedFailureSignature);
|
|
100
|
+
}
|
|
@@ -1,121 +1,36 @@
|
|
|
1
|
+
import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionChangesRequested, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
|
|
1
2
|
import { isMainRepairIssue } from "./main-repair.js";
|
|
2
3
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
3
|
-
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
4
4
|
import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
|
|
5
5
|
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
6
|
-
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
7
6
|
import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
|
|
8
7
|
import { getReviewFixBudget } from "./run-budgets.js";
|
|
9
8
|
import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
|
|
10
9
|
import { execCommand } from "./utils.js";
|
|
11
|
-
function isFailingCheckStatus(status) {
|
|
12
|
-
return status === "failed" || status === "failure";
|
|
13
|
-
}
|
|
14
|
-
function isReviewDecisionApproved(value) {
|
|
15
|
-
return value?.trim().toUpperCase() === "APPROVED";
|
|
16
|
-
}
|
|
17
|
-
function isReviewDecisionChangesRequested(value) {
|
|
18
|
-
return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
|
|
19
|
-
}
|
|
20
|
-
function isReviewDecisionReviewRequired(value) {
|
|
21
|
-
return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
|
|
22
|
-
}
|
|
23
|
-
function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
|
|
24
|
-
const promptContext = [
|
|
25
|
-
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
|
|
26
|
-
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
27
|
-
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
28
|
-
].join(" ");
|
|
29
|
-
return {
|
|
30
|
-
branchUpkeepRequired: true,
|
|
31
|
-
reviewFixMode: "branch_upkeep",
|
|
32
|
-
wakeReason: "branch_upkeep",
|
|
33
|
-
promptContext,
|
|
34
|
-
...(mergeStateStatus ? { mergeStateStatus } : {}),
|
|
35
|
-
...(headSha ? { failingHeadSha: headSha } : {}),
|
|
36
|
-
baseBranch,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
function hasCompletedReviewQuillVerdict(entries) {
|
|
40
|
-
return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
|
|
41
|
-
&& entry.name === "review-quill/verdict"
|
|
42
|
-
&& entry.status === "COMPLETED");
|
|
43
|
-
}
|
|
44
|
-
function getGateCheckNames(project) {
|
|
45
|
-
const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
46
|
-
return configured.length > 0 ? configured : ["verify"];
|
|
47
|
-
}
|
|
48
|
-
function isDuplicateRepairAttempt(issue, context) {
|
|
49
|
-
const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
|
|
50
|
-
const headSha = typeof context?.failureHeadSha === "string"
|
|
51
|
-
? context.failureHeadSha
|
|
52
|
-
: typeof context?.headSha === "string" ? context.headSha : undefined;
|
|
53
|
-
if (!signature)
|
|
54
|
-
return false;
|
|
55
|
-
if (issue.lastAttemptedFailureSignature !== signature)
|
|
56
|
-
return false;
|
|
57
|
-
if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
|
|
58
|
-
return false;
|
|
59
|
-
// A signature+headSha match alone isn't enough: for queue evictions the PR head
|
|
60
|
-
// doesn't advance (we haven't pushed) and the steward's check name is constant,
|
|
61
|
-
// so a fresh incident after main advances looks identical. Treat the attempt as
|
|
62
|
-
// stale if a newer failure has been observed since it was recorded.
|
|
63
|
-
if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
|
|
64
|
-
&& issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
function buildFailureContext(issue) {
|
|
70
|
-
const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
|
|
71
|
-
const queueRepairContext = issue.lastQueueIncidentJson
|
|
72
|
-
? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
|
|
73
|
-
: undefined;
|
|
74
|
-
if (!queueRepairContext
|
|
75
|
-
&& !issue.lastGitHubFailureSource
|
|
76
|
-
&& !issue.lastGitHubFailureHeadSha
|
|
77
|
-
&& !issue.lastGitHubFailureSignature
|
|
78
|
-
&& !issue.lastGitHubFailureCheckName
|
|
79
|
-
&& !issue.lastGitHubFailureCheckUrl
|
|
80
|
-
&& !storedFailureContext) {
|
|
81
|
-
return undefined;
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
|
|
85
|
-
...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
|
|
86
|
-
...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
|
|
87
|
-
...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
|
|
88
|
-
...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
89
|
-
...(storedFailureContext ? storedFailureContext : {}),
|
|
90
|
-
...(queueRepairContext ? queueRepairContext : {}),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
function hasFailureProvenance(issue) {
|
|
94
|
-
return Boolean(issue.lastGitHubFailureSource
|
|
95
|
-
|| issue.lastGitHubFailureHeadSha
|
|
96
|
-
|| issue.lastGitHubFailureSignature
|
|
97
|
-
|| issue.lastGitHubFailureCheckName
|
|
98
|
-
|| issue.lastGitHubFailureCheckUrl
|
|
99
|
-
|| issue.lastGitHubFailureContextJson
|
|
100
|
-
|| issue.lastGitHubFailureAt
|
|
101
|
-
|| issue.lastQueueIncidentJson
|
|
102
|
-
|| issue.lastAttemptedFailureHeadSha
|
|
103
|
-
|| issue.lastAttemptedFailureSignature);
|
|
104
|
-
}
|
|
105
10
|
export class IdleIssueReconciler {
|
|
106
11
|
db;
|
|
107
12
|
config;
|
|
108
|
-
|
|
13
|
+
wakeDispatcher;
|
|
109
14
|
logger;
|
|
110
15
|
feed;
|
|
111
|
-
constructor(db, config,
|
|
16
|
+
constructor(db, config, wakeDispatcher, logger, feed) {
|
|
112
17
|
this.db = db;
|
|
113
18
|
this.config = config;
|
|
114
|
-
this.
|
|
19
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
115
20
|
this.logger = logger;
|
|
116
21
|
this.feed = feed;
|
|
117
22
|
}
|
|
118
23
|
async reconcile() {
|
|
24
|
+
// Wrap the entire reconcile pass in a dispatcher tick. Every
|
|
25
|
+
// dispatchIfWakePending / recordEventAndDispatch call inside the
|
|
26
|
+
// callback automatically shares one dedupe Set, so a single pass
|
|
27
|
+
// produces at most one enqueue per issue even when several sub-
|
|
28
|
+
// passes detect the same wake. SerialWorkQueue would dedupe anyway,
|
|
29
|
+
// but keeping the call log clean makes orchestrator behaviour
|
|
30
|
+
// easier to inspect from tests and the operator feed.
|
|
31
|
+
return this.wakeDispatcher.withTick(() => this.reconcileBody());
|
|
32
|
+
}
|
|
33
|
+
async reconcileBody() {
|
|
119
34
|
for (const issue of this.db.issues.listIdleNonTerminalIssues()) {
|
|
120
35
|
if (issue.prState === "merged") {
|
|
121
36
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
@@ -161,15 +76,10 @@ export class IdleIssueReconciler {
|
|
|
161
76
|
continue;
|
|
162
77
|
const unresolved = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
163
78
|
if (unresolved === 0) {
|
|
164
|
-
this.
|
|
165
|
-
projectId: issue.projectId,
|
|
166
|
-
linearIssueId: issue.linearIssueId,
|
|
79
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
167
80
|
eventType: "delegated",
|
|
168
81
|
dedupeKey: `delegated:${issue.linearIssueId}`,
|
|
169
82
|
});
|
|
170
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
171
|
-
this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
172
|
-
}
|
|
173
83
|
}
|
|
174
84
|
}
|
|
175
85
|
const now = Date.now();
|
|
@@ -187,9 +97,19 @@ export class IdleIssueReconciler {
|
|
|
187
97
|
queueSettledOrchestrationIssue({
|
|
188
98
|
db: this.db,
|
|
189
99
|
issue,
|
|
190
|
-
|
|
100
|
+
wakeDispatcher: this.wakeDispatcher,
|
|
191
101
|
});
|
|
192
102
|
}
|
|
103
|
+
// Safety net: re-enqueue any idle delegated issue that still has
|
|
104
|
+
// unprocessed session events. Until this pass existed, a single
|
|
105
|
+
// dropped enqueueIssue (lease race, in-memory queue lost across
|
|
106
|
+
// restart) left review_fix / ci_repair / queue_repair wakes stuck
|
|
107
|
+
// for hours until an external event re-poked the issue. The
|
|
108
|
+
// surrounding withTick scope ensures the call log shows at most one
|
|
109
|
+
// enqueue per issue per pass even when earlier passes also queued.
|
|
110
|
+
for (const issue of this.db.issues.listIdleIssuesWithPendingWake()) {
|
|
111
|
+
this.wakeDispatcher.dispatchIfWakePending(issue.projectId, issue.linearIssueId);
|
|
112
|
+
}
|
|
193
113
|
}
|
|
194
114
|
shouldProbeTerminalIssueFromGitHub(issue) {
|
|
195
115
|
if (issue.prNumber === undefined)
|
|
@@ -232,7 +152,7 @@ export class IdleIssueReconciler {
|
|
|
232
152
|
: {}),
|
|
233
153
|
});
|
|
234
154
|
if (options?.pendingRunType) {
|
|
235
|
-
this.
|
|
155
|
+
this.recordWakeEvent(issue, options.pendingRunType, options.pendingRunContext, "idle_reconciliation");
|
|
236
156
|
}
|
|
237
157
|
this.feed?.publish({
|
|
238
158
|
level: "info",
|
|
@@ -243,11 +163,11 @@ export class IdleIssueReconciler {
|
|
|
243
163
|
status: "reconciled",
|
|
244
164
|
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
245
165
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
166
|
+
// The dispatcher's recordEventAndDispatch in recordWakeEvent already
|
|
167
|
+
// handles the enqueue when no run is in flight, so no extra poke
|
|
168
|
+
// is needed here.
|
|
249
169
|
}
|
|
250
|
-
|
|
170
|
+
recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
|
|
251
171
|
let eventType;
|
|
252
172
|
let dedupeKey;
|
|
253
173
|
if (runType === "queue_repair") {
|
|
@@ -266,9 +186,7 @@ export class IdleIssueReconciler {
|
|
|
266
186
|
eventType = "delegated";
|
|
267
187
|
dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
|
|
268
188
|
}
|
|
269
|
-
this.
|
|
270
|
-
projectId: issue.projectId,
|
|
271
|
-
linearIssueId: issue.linearIssueId,
|
|
189
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
272
190
|
eventType,
|
|
273
191
|
...(context ? { eventJson: JSON.stringify(context) } : {}),
|
|
274
192
|
dedupeKey,
|
|
@@ -14,14 +14,14 @@ export class MainBranchHealthMonitor {
|
|
|
14
14
|
db;
|
|
15
15
|
config;
|
|
16
16
|
linearProvider;
|
|
17
|
-
|
|
17
|
+
wakeDispatcher;
|
|
18
18
|
logger;
|
|
19
19
|
feed;
|
|
20
|
-
constructor(db, config, linearProvider,
|
|
20
|
+
constructor(db, config, linearProvider, wakeDispatcher, logger, feed) {
|
|
21
21
|
this.db = db;
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.linearProvider = linearProvider;
|
|
24
|
-
this.
|
|
24
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
25
25
|
this.logger = logger;
|
|
26
26
|
this.feed = feed;
|
|
27
27
|
}
|
|
@@ -82,9 +82,7 @@ export class MainBranchHealthMonitor {
|
|
|
82
82
|
branchName,
|
|
83
83
|
factoryState: "delegated",
|
|
84
84
|
});
|
|
85
|
-
this.
|
|
86
|
-
projectId,
|
|
87
|
-
linearIssueId: issue.linearIssueId,
|
|
85
|
+
this.wakeDispatcher.recordEventAndDispatch(projectId, issue.linearIssueId, {
|
|
88
86
|
eventType: "delegated",
|
|
89
87
|
eventJson: JSON.stringify({
|
|
90
88
|
runType: "main_repair",
|
|
@@ -96,9 +94,6 @@ export class MainBranchHealthMonitor {
|
|
|
96
94
|
}),
|
|
97
95
|
dedupeKey: `main_repair:${projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
98
96
|
});
|
|
99
|
-
if (this.db.issueSessions.peekIssueSessionWake(projectId, issue.linearIssueId)) {
|
|
100
|
-
this.enqueueIssue(projectId, issue.linearIssueId);
|
|
101
|
-
}
|
|
102
97
|
this.feed?.publish({
|
|
103
98
|
level: "warn",
|
|
104
99
|
kind: "github",
|
|
@@ -153,9 +148,7 @@ export class MainBranchHealthMonitor {
|
|
|
153
148
|
pendingRunContextJson: null,
|
|
154
149
|
activeRunId: null,
|
|
155
150
|
});
|
|
156
|
-
this.
|
|
157
|
-
projectId: issue.projectId,
|
|
158
|
-
linearIssueId: issue.linearIssueId,
|
|
151
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
159
152
|
eventType: "delegated",
|
|
160
153
|
eventJson: JSON.stringify({
|
|
161
154
|
runType: "main_repair",
|
|
@@ -167,9 +160,6 @@ export class MainBranchHealthMonitor {
|
|
|
167
160
|
}),
|
|
168
161
|
dedupeKey: `main_repair:${issue.projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
169
162
|
});
|
|
170
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
171
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
172
|
-
}
|
|
173
163
|
}
|
|
174
164
|
async resolveRecoveredMainRepair(issue) {
|
|
175
165
|
if (issue.activeRunId !== undefined)
|
|
@@ -89,7 +89,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
89
89
|
summary: "No PR found; continuing automatically",
|
|
90
90
|
detail: completionCheck.summary,
|
|
91
91
|
activity: buildCompletionCheckActivity("continue"),
|
|
92
|
-
enqueue: true,
|
|
93
92
|
});
|
|
94
93
|
return;
|
|
95
94
|
}
|
|
@@ -166,7 +165,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
166
165
|
summary: "No PR found; continuing automatically to finish publication",
|
|
167
166
|
detail: params.publishedOutcomeError,
|
|
168
167
|
activity: buildCompletionCheckActivity("continue"),
|
|
169
|
-
enqueue: true,
|
|
170
168
|
});
|
|
171
169
|
return;
|
|
172
170
|
}
|
|
@@ -211,7 +209,6 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
211
209
|
summary: "No repair PR found; continuing automatically",
|
|
212
210
|
detail: "Main repair cannot close until PatchRelay publishes a repair PR or main recovers externally.",
|
|
213
211
|
activity: buildCompletionCheckActivity("continue"),
|
|
214
|
-
enqueue: true,
|
|
215
212
|
});
|
|
216
213
|
return;
|
|
217
214
|
}
|
|
@@ -267,6 +264,7 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
267
264
|
db: params.db,
|
|
268
265
|
child: doneIssue,
|
|
269
266
|
eventType: "child_delivered",
|
|
267
|
+
wakeDispatcher: params.wakeDispatcher,
|
|
270
268
|
});
|
|
271
269
|
return;
|
|
272
270
|
}
|
|
@@ -37,9 +37,7 @@ export function queueSettledOrchestrationIssue(params) {
|
|
|
37
37
|
linearIssueId: params.issue.linearIssueId,
|
|
38
38
|
orchestrationSettleUntil: null,
|
|
39
39
|
});
|
|
40
|
-
params.
|
|
41
|
-
projectId: params.issue.projectId,
|
|
42
|
-
linearIssueId: params.issue.linearIssueId,
|
|
40
|
+
const dispatched = params.wakeDispatcher.recordEventAndDispatch(params.issue.projectId, params.issue.linearIssueId, {
|
|
43
41
|
eventType: "delegated",
|
|
44
42
|
eventJson: JSON.stringify({
|
|
45
43
|
...(params.promptContext
|
|
@@ -48,11 +46,7 @@ export function queueSettledOrchestrationIssue(params) {
|
|
|
48
46
|
}),
|
|
49
47
|
dedupeKey: `delegated:orchestration_settle:${params.issue.linearIssueId}`,
|
|
50
48
|
});
|
|
51
|
-
|
|
52
|
-
params.enqueueIssue?.(params.issue.projectId, params.issue.linearIssueId);
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
49
|
+
return dispatched !== undefined;
|
|
56
50
|
}
|
|
57
51
|
export function wakeOrchestrationParentsForChildEvent(params) {
|
|
58
52
|
const parentIds = [];
|
|
@@ -71,9 +65,7 @@ export function wakeOrchestrationParentsForChildEvent(params) {
|
|
|
71
65
|
parentIds.push(parent.linearIssueId);
|
|
72
66
|
continue;
|
|
73
67
|
}
|
|
74
|
-
params.
|
|
75
|
-
projectId: parent.projectId,
|
|
76
|
-
linearIssueId: parent.linearIssueId,
|
|
68
|
+
params.wakeDispatcher.recordEventAndDispatch(parent.projectId, parent.linearIssueId, {
|
|
77
69
|
eventType: params.eventType,
|
|
78
70
|
eventJson: JSON.stringify({
|
|
79
71
|
childIssueId: params.child.linearIssueId,
|
|
@@ -87,9 +79,6 @@ export function wakeOrchestrationParentsForChildEvent(params) {
|
|
|
87
79
|
}),
|
|
88
80
|
dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.changeKind ?? params.child.prState ?? "no-pr"}`,
|
|
89
81
|
});
|
|
90
|
-
if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
|
|
91
|
-
params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
|
|
92
|
-
}
|
|
93
82
|
parentIds.push(parent.linearIssueId);
|
|
94
83
|
}
|
|
95
84
|
return unique(parentIds);
|
|
@@ -149,17 +149,12 @@ export class QueueHealthMonitor {
|
|
|
149
149
|
lastAttemptedFailureHeadSha: headRefOid,
|
|
150
150
|
lastAttemptedFailureSignature: signature,
|
|
151
151
|
});
|
|
152
|
-
this.
|
|
153
|
-
projectId: issue.projectId,
|
|
154
|
-
linearIssueId: issue.linearIssueId,
|
|
152
|
+
this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
155
153
|
eventType: "merge_steward_incident",
|
|
156
154
|
eventJson: JSON.stringify(pendingRunContext),
|
|
157
155
|
dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
|
|
158
156
|
});
|
|
159
157
|
this.advancer.advanceIdleIssue(issue, "repairing_queue");
|
|
160
|
-
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
161
|
-
this.advancer.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
162
|
-
}
|
|
163
158
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
164
159
|
this.feed?.publish({
|
|
165
160
|
level: "warn",
|