patchrelay 0.50.6 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-session-plan.js +17 -4
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/StateHistoryView.js +1 -0
- package/dist/cli/watch/event-log-rows.js +1 -0
- package/dist/cli/watch/history-builder.js +1 -0
- package/dist/config.js +5 -0
- package/dist/idle-reconciliation.js +33 -0
- package/dist/implementation-outcome-policy.js +48 -5
- package/dist/issue-session-events.js +6 -9
- package/dist/issue-session.js +2 -0
- package/dist/linear-client.js +29 -0
- package/dist/main-branch-health-monitor.js +196 -0
- package/dist/main-repair.js +47 -0
- package/dist/merge-queue-protocol.js +2 -0
- package/dist/patchrelay-customization.js +1 -0
- package/dist/prompting/patchrelay.js +1 -0
- package/dist/run-launcher.js +5 -4
- package/dist/run-orchestrator.js +4 -0
- package/dist/run-wake-planner.js +4 -0
- package/package.json +1 -1
|
@@ -51,6 +51,14 @@ function ciRepairPlan(attempt) {
|
|
|
51
51
|
{ content: "Merge", status: "pending" },
|
|
52
52
|
];
|
|
53
53
|
}
|
|
54
|
+
function mainRepairPlan(attempt) {
|
|
55
|
+
return [
|
|
56
|
+
{ content: "Inspect main failure", status: "pending" },
|
|
57
|
+
{ content: `Repairing main (${attemptLabel(attempt)})`, status: "pending" },
|
|
58
|
+
{ content: "Awaiting re-verification", status: "pending" },
|
|
59
|
+
{ content: "Priority merge", status: "pending" },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
54
62
|
function queueRepairPlan(attempt) {
|
|
55
63
|
return [
|
|
56
64
|
{ content: "Prepare workspace", status: "completed" },
|
|
@@ -140,7 +148,9 @@ export function buildAgentSessionPlan(params) {
|
|
|
140
148
|
case "delegated":
|
|
141
149
|
return setStatuses(planForRunType(runType, params), ["inProgress", "pending", "pending", "pending"]);
|
|
142
150
|
case "implementing":
|
|
143
|
-
return setStatuses(
|
|
151
|
+
return setStatuses(params.activeRunType === "main_repair" || params.pendingRunType === "main_repair"
|
|
152
|
+
? mainRepairPlan(params.ciRepairAttempts ?? 1)
|
|
153
|
+
: planForRunType("implementation", params), ["completed", "inProgress", "pending", "pending"]);
|
|
144
154
|
case "pr_open":
|
|
145
155
|
return setStatuses(implementationPlan(), ["completed", "completed", "inProgress", "pending"]);
|
|
146
156
|
case "changes_requested":
|
|
@@ -173,6 +183,8 @@ export function buildAgentSessionPlan(params) {
|
|
|
173
183
|
}
|
|
174
184
|
function planForRunType(runType, params) {
|
|
175
185
|
switch (runType) {
|
|
186
|
+
case "main_repair":
|
|
187
|
+
return mainRepairPlan(params.ciRepairAttempts ?? 1);
|
|
176
188
|
case "review_fix":
|
|
177
189
|
return reviewFixPlan();
|
|
178
190
|
case "branch_upkeep":
|
|
@@ -200,9 +212,10 @@ export function buildAgentSessionPlanForIssue(issue, options) {
|
|
|
200
212
|
export function buildRunningSessionPlan(runType) {
|
|
201
213
|
return buildAgentSessionPlan({
|
|
202
214
|
factoryState: runType === "ci_repair" ? "repairing_ci"
|
|
203
|
-
: runType === "
|
|
204
|
-
: runType === "
|
|
205
|
-
: "
|
|
215
|
+
: runType === "main_repair" ? "implementing"
|
|
216
|
+
: runType === "review_fix" || runType === "branch_upkeep" ? "changes_requested"
|
|
217
|
+
: runType === "queue_repair" ? "repairing_queue"
|
|
218
|
+
: "implementing",
|
|
206
219
|
activeRunType: runType,
|
|
207
220
|
});
|
|
208
221
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const SIDE_TRIP_STATES = new Set(["changes_requested", "repairing_ci", "repairing_queue"]);
|
|
3
3
|
const RUN_TYPE_TO_STATE = {
|
|
4
4
|
implementation: "implementing",
|
|
5
|
+
main_repair: "implementing",
|
|
5
6
|
ci_repair: "repairing_ci",
|
|
6
7
|
review_fix: "changes_requested",
|
|
7
8
|
branch_upkeep: "changes_requested",
|
package/dist/config.js
CHANGED
|
@@ -66,6 +66,7 @@ const projectSchema = z.object({
|
|
|
66
66
|
webhook_secret: z.string().min(1).optional(),
|
|
67
67
|
repo_full_name: z.string().min(1).optional(),
|
|
68
68
|
base_branch: z.string().min(1).optional(),
|
|
69
|
+
priority_queue_label: z.string().min(1).optional(),
|
|
69
70
|
}).optional(),
|
|
70
71
|
});
|
|
71
72
|
const repositorySchema = z.object({
|
|
@@ -89,6 +90,7 @@ const repositorySchema = z.object({
|
|
|
89
90
|
base_branch: z.string().min(1).optional(),
|
|
90
91
|
merge_queue_label: z.string().min(1).optional(),
|
|
91
92
|
merge_queue_check_name: z.string().min(1).optional(),
|
|
93
|
+
priority_queue_label: z.string().min(1).optional(),
|
|
92
94
|
}).optional(),
|
|
93
95
|
});
|
|
94
96
|
const promptLayerSchema = z.object({
|
|
@@ -97,6 +99,7 @@ const promptLayerSchema = z.object({
|
|
|
97
99
|
});
|
|
98
100
|
const promptByRunTypeSchema = z.object({
|
|
99
101
|
implementation: promptLayerSchema.optional(),
|
|
102
|
+
main_repair: promptLayerSchema.optional(),
|
|
100
103
|
review_fix: promptLayerSchema.optional(),
|
|
101
104
|
branch_upkeep: promptLayerSchema.optional(),
|
|
102
105
|
ci_repair: promptLayerSchema.optional(),
|
|
@@ -474,6 +477,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
474
477
|
...(repository.github?.webhook_secret ? { webhookSecret: repository.github.webhook_secret } : {}),
|
|
475
478
|
...(repository.github?.merge_queue_label ? { mergeQueueLabel: repository.github.merge_queue_label } : {}),
|
|
476
479
|
...(repository.github?.merge_queue_check_name ? { mergeQueueCheckName: repository.github.merge_queue_check_name } : {}),
|
|
480
|
+
...(repository.github?.priority_queue_label ? { priorityQueueLabel: repository.github.priority_queue_label } : {}),
|
|
477
481
|
},
|
|
478
482
|
};
|
|
479
483
|
});
|
|
@@ -514,6 +518,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
514
518
|
...(project.github.webhook_secret ? { webhookSecret: project.github.webhook_secret } : {}),
|
|
515
519
|
...(project.github.repo_full_name ? { repoFullName: project.github.repo_full_name } : {}),
|
|
516
520
|
...(project.github.base_branch ? { baseBranch: project.github.base_branch } : {}),
|
|
521
|
+
...(project.github.priority_queue_label ? { priorityQueueLabel: project.github.priority_queue_label } : {}),
|
|
517
522
|
},
|
|
518
523
|
} : {}),
|
|
519
524
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isMainRepairIssue } from "./main-repair.js";
|
|
1
2
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
3
|
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
3
4
|
import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
|
|
@@ -421,6 +422,35 @@ export class IdleIssueReconciler {
|
|
|
421
422
|
const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
|
|
422
423
|
return resolveMergeQueueProtocol(project);
|
|
423
424
|
}
|
|
425
|
+
async ensurePriorityQueueLabel(issue, repoFullName) {
|
|
426
|
+
if (!isMainRepairIssue(issue) || !issue.prNumber)
|
|
427
|
+
return;
|
|
428
|
+
const priorityLabel = this.getIssueProtocol(issue).priorityLabel;
|
|
429
|
+
try {
|
|
430
|
+
const { stdout } = await execCommand("gh", [
|
|
431
|
+
"pr", "view", String(issue.prNumber),
|
|
432
|
+
"--repo", repoFullName,
|
|
433
|
+
"--json", "labels",
|
|
434
|
+
], { timeoutMs: 10_000 });
|
|
435
|
+
const payload = JSON.parse(stdout);
|
|
436
|
+
if ((payload.labels ?? []).some((entry) => entry.name === priorityLabel)) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
await execCommand("gh", [
|
|
440
|
+
"pr", "edit", String(issue.prNumber),
|
|
441
|
+
"--repo", repoFullName,
|
|
442
|
+
"--add-label", priorityLabel,
|
|
443
|
+
], { timeoutMs: 10_000 });
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
this.logger.warn({
|
|
447
|
+
issueKey: issue.issueKey,
|
|
448
|
+
prNumber: issue.prNumber,
|
|
449
|
+
priorityLabel,
|
|
450
|
+
error: error instanceof Error ? error.message : String(error),
|
|
451
|
+
}, "Reconciliation: failed to enforce priority queue label");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
424
454
|
async reconcileFromGitHub(issue) {
|
|
425
455
|
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
426
456
|
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
@@ -435,6 +465,9 @@ export class IdleIssueReconciler {
|
|
|
435
465
|
const previousHeadSha = issue.prHeadSha;
|
|
436
466
|
const gateCheckNames = getGateCheckNames(project);
|
|
437
467
|
const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
|
|
468
|
+
if (pr.state === "OPEN") {
|
|
469
|
+
await this.ensurePriorityQueueLabel(issue, project.github.repoFullName);
|
|
470
|
+
}
|
|
438
471
|
this.db.issues.upsertIssue({
|
|
439
472
|
projectId: issue.projectId,
|
|
440
473
|
linearIssueId: issue.linearIssueId,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isMainRepairIssue } from "./main-repair.js";
|
|
2
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
1
3
|
import { execCommand } from "./utils.js";
|
|
2
4
|
export class ImplementationOutcomePolicy {
|
|
3
5
|
config;
|
|
@@ -11,12 +13,12 @@ export class ImplementationOutcomePolicy {
|
|
|
11
13
|
this.withHeldLease = withHeldLease;
|
|
12
14
|
}
|
|
13
15
|
async verifyPublishedRunOutcome(run, issue) {
|
|
14
|
-
if (run.runType !== "implementation") {
|
|
16
|
+
if (run.runType !== "implementation" && run.runType !== "main_repair") {
|
|
15
17
|
return undefined;
|
|
16
18
|
}
|
|
17
19
|
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
18
20
|
const baseBranch = project?.github?.baseBranch ?? "main";
|
|
19
|
-
const publishedPrState = await this.detectPublishedPrState(issue, project?.github?.repoFullName);
|
|
21
|
+
const publishedPrState = await this.detectPublishedPrState(run, issue, project?.github?.repoFullName);
|
|
20
22
|
if (publishedPrState === "open") {
|
|
21
23
|
return undefined;
|
|
22
24
|
}
|
|
@@ -24,18 +26,18 @@ export class ImplementationOutcomePolicy {
|
|
|
24
26
|
return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
|
|
25
27
|
}
|
|
26
28
|
async detectRecoverableFailedImplementationOutcome(run, issue) {
|
|
27
|
-
if (run.runType !== "implementation") {
|
|
29
|
+
if (run.runType !== "implementation" && run.runType !== "main_repair") {
|
|
28
30
|
return undefined;
|
|
29
31
|
}
|
|
30
32
|
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
31
|
-
const publishedPrState = await this.detectPublishedPrState(issue, project?.github?.repoFullName);
|
|
33
|
+
const publishedPrState = await this.detectPublishedPrState(run, issue, project?.github?.repoFullName);
|
|
32
34
|
if (publishedPrState === "open" || publishedPrState === "unknown") {
|
|
33
35
|
return undefined;
|
|
34
36
|
}
|
|
35
37
|
const baseBranch = project?.github?.baseBranch ?? "main";
|
|
36
38
|
return await this.describeLocalImplementationOutcome(issue, baseBranch);
|
|
37
39
|
}
|
|
38
|
-
async detectPublishedPrState(issue, repoFullName) {
|
|
40
|
+
async detectPublishedPrState(run, issue, repoFullName) {
|
|
39
41
|
if (issue.prNumber && issue.prState && issue.prState !== "closed") {
|
|
40
42
|
return "open";
|
|
41
43
|
}
|
|
@@ -73,6 +75,9 @@ export class ImplementationOutcomePolicy {
|
|
|
73
75
|
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
74
76
|
...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
|
|
75
77
|
}, "published PR verification refresh");
|
|
78
|
+
if (state !== "closed" && isMainRepairIssue(issue)) {
|
|
79
|
+
await this.ensurePriorityQueueLabel(run.projectId, pr.number, repoFullName);
|
|
80
|
+
}
|
|
76
81
|
return state === "closed" ? "closed" : "open";
|
|
77
82
|
}
|
|
78
83
|
catch (error) {
|
|
@@ -133,4 +138,42 @@ export class ImplementationOutcomePolicy {
|
|
|
133
138
|
}
|
|
134
139
|
return undefined;
|
|
135
140
|
}
|
|
141
|
+
async ensurePriorityQueueLabel(projectId, prNumber, repoFullName) {
|
|
142
|
+
const project = this.config.projects.find((entry) => entry.id === projectId);
|
|
143
|
+
if (!project || !repoFullName)
|
|
144
|
+
return;
|
|
145
|
+
const priorityLabel = resolveMergeQueueProtocol(project).priorityLabel;
|
|
146
|
+
try {
|
|
147
|
+
const { stdout } = await execCommand("gh", [
|
|
148
|
+
"pr",
|
|
149
|
+
"view",
|
|
150
|
+
String(prNumber),
|
|
151
|
+
"--repo",
|
|
152
|
+
repoFullName,
|
|
153
|
+
"--json",
|
|
154
|
+
"labels",
|
|
155
|
+
], { timeoutMs: 10_000 });
|
|
156
|
+
const labels = JSON.parse(stdout);
|
|
157
|
+
const hasLabel = (labels.labels ?? []).some((entry) => entry.name === priorityLabel);
|
|
158
|
+
if (hasLabel)
|
|
159
|
+
return;
|
|
160
|
+
await execCommand("gh", [
|
|
161
|
+
"pr",
|
|
162
|
+
"edit",
|
|
163
|
+
String(prNumber),
|
|
164
|
+
"--repo",
|
|
165
|
+
repoFullName,
|
|
166
|
+
"--add-label",
|
|
167
|
+
priorityLabel,
|
|
168
|
+
], { timeoutMs: 10_000 });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
this.logger.warn({
|
|
172
|
+
projectId,
|
|
173
|
+
prNumber,
|
|
174
|
+
priorityLabel,
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
}, "Failed to enforce priority queue label on main repair PR");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
136
179
|
}
|
|
@@ -11,7 +11,7 @@ const NON_ACTIONABLE_SESSION_EVENTS = new Set([
|
|
|
11
11
|
"delegation_observed",
|
|
12
12
|
"run_released_authority",
|
|
13
13
|
]);
|
|
14
|
-
const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
|
|
14
|
+
const RUN_TYPES = new Set(["implementation", "main_repair", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
|
|
15
15
|
function parseRunType(value) {
|
|
16
16
|
return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
|
|
17
17
|
}
|
|
@@ -51,15 +51,12 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
51
51
|
break;
|
|
52
52
|
case "delegated":
|
|
53
53
|
if (!runType) {
|
|
54
|
-
runType = "implementation";
|
|
55
|
-
wakeReason =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
context.promptContext = payload.promptContext;
|
|
59
|
-
}
|
|
60
|
-
if (payload?.promptBody !== undefined) {
|
|
61
|
-
context.promptBody = payload.promptBody;
|
|
54
|
+
runType = parseRunType(payload?.runType) ?? "implementation";
|
|
55
|
+
wakeReason = runType === "main_repair"
|
|
56
|
+
? "main_repair"
|
|
57
|
+
: issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
|
|
62
58
|
}
|
|
59
|
+
Object.assign(context, payload ?? {});
|
|
63
60
|
break;
|
|
64
61
|
case "child_changed":
|
|
65
62
|
case "child_delivered":
|
package/dist/issue-session.js
CHANGED
|
@@ -18,6 +18,8 @@ export function deriveIssueSessionWakeReason(params) {
|
|
|
18
18
|
return undefined;
|
|
19
19
|
if (params.pendingRunType === "implementation")
|
|
20
20
|
return "delegated";
|
|
21
|
+
if (params.pendingRunType === "main_repair")
|
|
22
|
+
return "main_repair";
|
|
21
23
|
if (params.pendingRunType === "review_fix")
|
|
22
24
|
return "review_changes_requested";
|
|
23
25
|
if (params.pendingRunType === "branch_upkeep")
|
package/dist/linear-client.js
CHANGED
|
@@ -109,6 +109,35 @@ export class LinearGraphqlClient {
|
|
|
109
109
|
}
|
|
110
110
|
return this.mapIssue(response.issue);
|
|
111
111
|
}
|
|
112
|
+
async createIssue(params) {
|
|
113
|
+
const response = await this.request(`
|
|
114
|
+
mutation PatchRelayCreateIssue($input: IssueCreateInput!) {
|
|
115
|
+
issueCreate(input: $input) {
|
|
116
|
+
success
|
|
117
|
+
issue {
|
|
118
|
+
${LINEAR_ISSUE_SELECTION}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
`, {
|
|
123
|
+
input: {
|
|
124
|
+
teamId: params.teamId,
|
|
125
|
+
title: params.title,
|
|
126
|
+
...(params.description ? { description: params.description } : {}),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!response.issueCreate.success || !response.issueCreate.issue) {
|
|
130
|
+
throw new Error(`Linear rejected issue creation for team ${params.teamId}`);
|
|
131
|
+
}
|
|
132
|
+
let issue = this.mapIssue(response.issueCreate.issue);
|
|
133
|
+
if (params.labelNames && params.labelNames.length > 0) {
|
|
134
|
+
issue = await this.updateIssueLabels({
|
|
135
|
+
issueId: issue.id,
|
|
136
|
+
addNames: params.labelNames,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return issue;
|
|
140
|
+
}
|
|
112
141
|
async setIssueState(issueId, stateName) {
|
|
113
142
|
const issue = await this.getIssue(issueId);
|
|
114
143
|
const state = issue.workflowStates.find((entry) => entry.name.trim().toLowerCase() === stateName.trim().toLowerCase());
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
|
+
import { buildMainRepairBranchName, buildMainRepairDescription, buildMainRepairPromptContext, buildMainRepairTitle, isMainRepairIssue, } from "./main-repair.js";
|
|
3
|
+
import { execCommand } from "./utils.js";
|
|
4
|
+
const MAIN_BRANCH_HEALTH_GRACE_MS = 120_000;
|
|
5
|
+
function isUnhealthyMainConclusion(conclusion) {
|
|
6
|
+
return conclusion === "failure"
|
|
7
|
+
|| conclusion === "timed_out"
|
|
8
|
+
|| conclusion === "cancelled"
|
|
9
|
+
|| conclusion === "action_required"
|
|
10
|
+
|| conclusion === "stale";
|
|
11
|
+
}
|
|
12
|
+
export class MainBranchHealthMonitor {
|
|
13
|
+
db;
|
|
14
|
+
config;
|
|
15
|
+
linearProvider;
|
|
16
|
+
enqueueIssue;
|
|
17
|
+
logger;
|
|
18
|
+
feed;
|
|
19
|
+
constructor(db, config, linearProvider, enqueueIssue, logger, feed) {
|
|
20
|
+
this.db = db;
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.linearProvider = linearProvider;
|
|
23
|
+
this.enqueueIssue = enqueueIssue;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
this.feed = feed;
|
|
26
|
+
}
|
|
27
|
+
async reconcile() {
|
|
28
|
+
for (const project of this.config.projects) {
|
|
29
|
+
await this.reconcileProject(project.id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async reconcileProject(projectId) {
|
|
33
|
+
const project = this.config.projects.find((entry) => entry.id === projectId);
|
|
34
|
+
if (!project?.github?.repoFullName)
|
|
35
|
+
return;
|
|
36
|
+
if (project.linearTeamIds.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const baseBranch = project.github.baseBranch ?? "main";
|
|
39
|
+
const branchName = buildMainRepairBranchName(baseBranch);
|
|
40
|
+
const existing = this.db.listIssues().find((issue) => (issue.projectId === projectId
|
|
41
|
+
&& issue.branchName === branchName
|
|
42
|
+
&& isMainRepairIssue(issue)
|
|
43
|
+
&& issue.factoryState !== "done"
|
|
44
|
+
&& issue.factoryState !== "failed"
|
|
45
|
+
&& issue.factoryState !== "escalated"));
|
|
46
|
+
const summary = await this.readMainBranchFailure(project.github.repoFullName, baseBranch);
|
|
47
|
+
if (!summary) {
|
|
48
|
+
if (existing) {
|
|
49
|
+
this.resolveRecoveredMainRepair(existing);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
54
|
+
if (existing) {
|
|
55
|
+
const age = Date.now() - Date.parse(existing.updatedAt);
|
|
56
|
+
if (age < MAIN_BRANCH_HEALTH_GRACE_MS) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (existing) {
|
|
61
|
+
this.queueExistingMainRepair(existing, summary, protocol.priorityLabel);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const client = await this.linearProvider.forProject(projectId);
|
|
65
|
+
if (!client?.createIssue) {
|
|
66
|
+
this.logger.warn({ projectId, repoFullName: project.github.repoFullName }, "Cannot create main repair issue because Linear issue creation is unavailable");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const created = await client.createIssue({
|
|
70
|
+
teamId: project.linearTeamIds[0],
|
|
71
|
+
title: buildMainRepairTitle(project),
|
|
72
|
+
description: buildMainRepairDescription(project, summary, protocol.priorityLabel),
|
|
73
|
+
});
|
|
74
|
+
const issue = this.db.upsertIssue({
|
|
75
|
+
projectId,
|
|
76
|
+
linearIssueId: created.id,
|
|
77
|
+
delegatedToPatchRelay: true,
|
|
78
|
+
...(created.identifier ? { issueKey: created.identifier } : {}),
|
|
79
|
+
...(created.title ? { title: created.title } : {}),
|
|
80
|
+
...(created.description ? { description: created.description } : {}),
|
|
81
|
+
...(created.url ? { url: created.url } : {}),
|
|
82
|
+
...(created.priority != null ? { priority: created.priority } : {}),
|
|
83
|
+
...(created.estimate != null ? { estimate: created.estimate } : {}),
|
|
84
|
+
...(created.stateName ? { currentLinearState: created.stateName } : {}),
|
|
85
|
+
...(created.stateType ? { currentLinearStateType: created.stateType } : {}),
|
|
86
|
+
branchName,
|
|
87
|
+
factoryState: "delegated",
|
|
88
|
+
});
|
|
89
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, issue.linearIssueId, {
|
|
90
|
+
projectId,
|
|
91
|
+
linearIssueId: issue.linearIssueId,
|
|
92
|
+
eventType: "delegated",
|
|
93
|
+
eventJson: JSON.stringify({
|
|
94
|
+
runType: "main_repair",
|
|
95
|
+
baseSha: summary.baseSha,
|
|
96
|
+
failingChecks: summary.failingChecks,
|
|
97
|
+
pendingChecks: summary.pendingChecks,
|
|
98
|
+
priorityLabel: protocol.priorityLabel,
|
|
99
|
+
promptContext: buildMainRepairPromptContext(project, summary, protocol.priorityLabel),
|
|
100
|
+
}),
|
|
101
|
+
dedupeKey: `main_repair:${projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
102
|
+
});
|
|
103
|
+
if (this.db.issueSessions.peekIssueSessionWake(projectId, issue.linearIssueId)) {
|
|
104
|
+
this.enqueueIssue(projectId, issue.linearIssueId);
|
|
105
|
+
}
|
|
106
|
+
this.feed?.publish({
|
|
107
|
+
level: "warn",
|
|
108
|
+
kind: "github",
|
|
109
|
+
issueKey: issue.issueKey,
|
|
110
|
+
projectId,
|
|
111
|
+
stage: "delegated",
|
|
112
|
+
status: "main_repair_queued",
|
|
113
|
+
summary: `Queued main_repair for ${project.github.repoFullName}@${baseBranch}`,
|
|
114
|
+
detail: summary.failingChecks.map((check) => check.name).join(", "),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
queueExistingMainRepair(issue, summary, priorityLabel) {
|
|
118
|
+
if (issue.activeRunId !== undefined)
|
|
119
|
+
return;
|
|
120
|
+
if (this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId))
|
|
121
|
+
return;
|
|
122
|
+
if (issue.prState === "open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "pr_open")
|
|
123
|
+
return;
|
|
124
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
125
|
+
projectId: issue.projectId,
|
|
126
|
+
linearIssueId: issue.linearIssueId,
|
|
127
|
+
eventType: "delegated",
|
|
128
|
+
eventJson: JSON.stringify({
|
|
129
|
+
runType: "main_repair",
|
|
130
|
+
baseSha: summary.baseSha,
|
|
131
|
+
failingChecks: summary.failingChecks,
|
|
132
|
+
pendingChecks: summary.pendingChecks,
|
|
133
|
+
priorityLabel,
|
|
134
|
+
}),
|
|
135
|
+
dedupeKey: `main_repair:${issue.projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
136
|
+
});
|
|
137
|
+
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
138
|
+
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
resolveRecoveredMainRepair(issue) {
|
|
142
|
+
if (issue.activeRunId !== undefined)
|
|
143
|
+
return;
|
|
144
|
+
if (issue.prState === "open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "pr_open") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
148
|
+
this.db.upsertIssue({
|
|
149
|
+
projectId: issue.projectId,
|
|
150
|
+
linearIssueId: issue.linearIssueId,
|
|
151
|
+
factoryState: "done",
|
|
152
|
+
pendingRunType: null,
|
|
153
|
+
});
|
|
154
|
+
this.feed?.publish({
|
|
155
|
+
level: "info",
|
|
156
|
+
kind: "github",
|
|
157
|
+
issueKey: issue.issueKey,
|
|
158
|
+
projectId: issue.projectId,
|
|
159
|
+
stage: "done",
|
|
160
|
+
status: "main_repair_resolved",
|
|
161
|
+
summary: "Closed stale main_repair after main recovered externally",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async readMainBranchFailure(repoFullName, baseBranch) {
|
|
165
|
+
const { stdout: shaOut } = await execCommand("gh", [
|
|
166
|
+
"api",
|
|
167
|
+
`repos/${repoFullName}/branches/${baseBranch}`,
|
|
168
|
+
"--jq",
|
|
169
|
+
".commit.sha",
|
|
170
|
+
], { timeoutMs: 10_000 });
|
|
171
|
+
const baseSha = shaOut.trim();
|
|
172
|
+
if (!baseSha)
|
|
173
|
+
return undefined;
|
|
174
|
+
const { stdout: checksOut } = await execCommand("gh", [
|
|
175
|
+
"api",
|
|
176
|
+
`repos/${repoFullName}/commits/${baseSha}/check-runs`,
|
|
177
|
+
"--jq",
|
|
178
|
+
".check_runs",
|
|
179
|
+
], { timeoutMs: 10_000 });
|
|
180
|
+
const runs = JSON.parse(checksOut || "[]");
|
|
181
|
+
const failingChecks = runs
|
|
182
|
+
.filter((run) => run.status === "completed" && isUnhealthyMainConclusion(run.conclusion) && typeof run.name === "string" && run.name.trim())
|
|
183
|
+
.map((run) => ({ name: run.name.trim(), ...(run.details_url ? { url: run.details_url } : {}) }));
|
|
184
|
+
if (failingChecks.length === 0) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const pendingChecks = runs
|
|
188
|
+
.filter((run) => run.status !== "completed" && typeof run.name === "string" && run.name.trim())
|
|
189
|
+
.map((run) => ({ name: run.name.trim(), ...(run.details_url ? { url: run.details_url } : {}) }));
|
|
190
|
+
return {
|
|
191
|
+
baseSha,
|
|
192
|
+
failingChecks,
|
|
193
|
+
pendingChecks,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const MAIN_REPAIR_BRANCH_PREFIX = "main-repair";
|
|
2
|
+
export function buildMainRepairBranchName(baseBranch) {
|
|
3
|
+
return `${MAIN_REPAIR_BRANCH_PREFIX}/${baseBranch}`;
|
|
4
|
+
}
|
|
5
|
+
export function isMainRepairIssue(issue) {
|
|
6
|
+
return typeof issue.branchName === "string" && issue.branchName.startsWith(`${MAIN_REPAIR_BRANCH_PREFIX}/`);
|
|
7
|
+
}
|
|
8
|
+
export function buildMainRepairTitle(project) {
|
|
9
|
+
const repo = project.github?.repoFullName ?? project.id;
|
|
10
|
+
const baseBranch = project.github?.baseBranch ?? "main";
|
|
11
|
+
return `Repair ${baseBranch} for ${repo}`;
|
|
12
|
+
}
|
|
13
|
+
export function buildMainRepairDescription(project, summary, priorityLabel) {
|
|
14
|
+
const repo = project.github?.repoFullName ?? project.id;
|
|
15
|
+
const baseBranch = project.github?.baseBranch ?? "main";
|
|
16
|
+
const lines = [
|
|
17
|
+
`Automatically created because \`${repo}@${baseBranch}\` is red.`,
|
|
18
|
+
"",
|
|
19
|
+
`Base SHA: \`${summary.baseSha}\``,
|
|
20
|
+
"",
|
|
21
|
+
"Repair the base-branch failure on a PR branch, get the PR green, and keep it in the priority queue lane.",
|
|
22
|
+
`The repair PR must carry the GitHub label \`${priorityLabel}\`.`,
|
|
23
|
+
];
|
|
24
|
+
if (summary.failingChecks.length > 0) {
|
|
25
|
+
lines.push("", "Failing checks:");
|
|
26
|
+
for (const check of summary.failingChecks) {
|
|
27
|
+
lines.push(`- ${check.name}${check.url ? ` — ${check.url}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (summary.pendingChecks.length > 0) {
|
|
31
|
+
lines.push("", "Pending checks:");
|
|
32
|
+
for (const check of summary.pendingChecks) {
|
|
33
|
+
lines.push(`- ${check.name}${check.url ? ` — ${check.url}` : ""}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
export function buildMainRepairPromptContext(project, summary, priorityLabel) {
|
|
39
|
+
const repo = project.github?.repoFullName ?? project.id;
|
|
40
|
+
const baseBranch = project.github?.baseBranch ?? "main";
|
|
41
|
+
const failingNames = summary.failingChecks.map((check) => check.name).join(", ") || "unknown failing checks";
|
|
42
|
+
return [
|
|
43
|
+
`Main repair for ${repo}.`,
|
|
44
|
+
`${baseBranch} is red at ${summary.baseSha}.`,
|
|
45
|
+
`Fix the failing base-branch checks (${failingNames}), publish a PR on this branch, and assign the GitHub label ${priorityLabel}.`,
|
|
46
|
+
].join(" ");
|
|
47
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export const DEFAULT_MERGE_QUEUE_LABEL = "queue";
|
|
2
2
|
export const DEFAULT_MERGE_QUEUE_CHECK_NAME = "merge-steward/queue";
|
|
3
|
+
export const DEFAULT_PRIORITY_QUEUE_LABEL = "queue:priority";
|
|
3
4
|
export function resolveMergeQueueProtocol(project) {
|
|
4
5
|
return {
|
|
5
6
|
repoFullName: project?.github?.repoFullName,
|
|
6
7
|
baseBranch: project?.github?.baseBranch,
|
|
7
8
|
admissionLabel: project?.github?.mergeQueueLabel ?? DEFAULT_MERGE_QUEUE_LABEL,
|
|
8
9
|
evictionCheckName: project?.github?.mergeQueueCheckName ?? DEFAULT_MERGE_QUEUE_CHECK_NAME,
|
|
10
|
+
priorityLabel: project?.github?.priorityQueueLabel ?? DEFAULT_PRIORITY_QUEUE_LABEL,
|
|
9
11
|
};
|
|
10
12
|
}
|
|
@@ -7,6 +7,7 @@ const promptLayerSchema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
const promptByRunTypeSchema = z.object({
|
|
9
9
|
implementation: promptLayerSchema.optional(),
|
|
10
|
+
main_repair: promptLayerSchema.optional(),
|
|
10
11
|
review_fix: promptLayerSchema.optional(),
|
|
11
12
|
branch_upkeep: promptLayerSchema.optional(),
|
|
12
13
|
ci_repair: promptLayerSchema.optional(),
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { derivePrDisplayContext } from "../pr-display-context.js";
|
|
4
4
|
const WORKFLOW_FILES = {
|
|
5
5
|
implementation: "IMPLEMENTATION_WORKFLOW.md",
|
|
6
|
+
main_repair: "IMPLEMENTATION_WORKFLOW.md",
|
|
6
7
|
review_fix: "REVIEW_WORKFLOW.md",
|
|
7
8
|
branch_upkeep: "REVIEW_WORKFLOW.md",
|
|
8
9
|
ci_repair: "IMPLEMENTATION_WORKFLOW.md",
|
package/dist/run-launcher.js
CHANGED
|
@@ -93,10 +93,11 @@ export class RunLauncher {
|
|
|
93
93
|
branchName: params.branchName,
|
|
94
94
|
worktreePath: params.worktreePath,
|
|
95
95
|
factoryState: params.runType === "implementation" ? "implementing"
|
|
96
|
-
: params.runType === "
|
|
97
|
-
: params.runType === "
|
|
98
|
-
: params.runType === "
|
|
99
|
-
: "
|
|
96
|
+
: params.runType === "main_repair" ? "implementing"
|
|
97
|
+
: params.runType === "ci_repair" ? "repairing_ci"
|
|
98
|
+
: params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
|
|
99
|
+
: params.runType === "queue_repair" ? "repairing_queue"
|
|
100
|
+
: "implementing",
|
|
100
101
|
...((params.runType === "ci_repair" || params.runType === "queue_repair") && failureSignature
|
|
101
102
|
? {
|
|
102
103
|
lastAttemptedFailureSignature: failureSignature,
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -3,6 +3,7 @@ import { buildRunStartedActivity, } from "./linear-session-reporting.js";
|
|
|
3
3
|
import { CompletionCheckService } from "./completion-check.js";
|
|
4
4
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
5
5
|
import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
|
|
6
|
+
import { MainBranchHealthMonitor } from "./main-branch-health-monitor.js";
|
|
6
7
|
import { QueueHealthMonitor } from "./queue-health-monitor.js";
|
|
7
8
|
import { IdleIssueReconciler } from "./idle-reconciliation.js";
|
|
8
9
|
import { LinearSessionSync } from "./linear-session-sync.js";
|
|
@@ -44,6 +45,7 @@ export class RunOrchestrator {
|
|
|
44
45
|
feed;
|
|
45
46
|
configPath;
|
|
46
47
|
worktreeManager;
|
|
48
|
+
mainBranchHealthMonitor;
|
|
47
49
|
/** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
|
|
48
50
|
queueHealthMonitor;
|
|
49
51
|
idleReconciler;
|
|
@@ -103,6 +105,7 @@ export class RunOrchestrator {
|
|
|
103
105
|
this.idleReconciler = new IdleIssueReconciler(db, config, {
|
|
104
106
|
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
105
107
|
}, logger, feed);
|
|
108
|
+
this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, (projectId, issueId) => this.enqueueIssue(projectId, issueId), logger, feed);
|
|
106
109
|
this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
|
|
107
110
|
this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
|
|
108
111
|
advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
|
|
@@ -351,6 +354,7 @@ export class RunOrchestrator {
|
|
|
351
354
|
for (const run of this.db.runs.listRunningRuns()) {
|
|
352
355
|
await this.reconcileRun(run);
|
|
353
356
|
}
|
|
357
|
+
await this.mainBranchHealthMonitor.reconcile();
|
|
354
358
|
// Preemptively detect stuck merge-queue PRs (conflicts visible on
|
|
355
359
|
// GitHub) and dispatch queue_repair before the Steward evicts.
|
|
356
360
|
await this.queueHealthMonitor.reconcile();
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -27,6 +27,10 @@ export class RunWakePlanner {
|
|
|
27
27
|
eventType = "settled_red_ci";
|
|
28
28
|
dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
|
|
29
29
|
}
|
|
30
|
+
else if (runType === "main_repair") {
|
|
31
|
+
eventType = "delegated";
|
|
32
|
+
dedupeKey = `${dedupeScope ?? "wake"}:main_repair:${issue.linearIssueId}`;
|
|
33
|
+
}
|
|
30
34
|
else if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
31
35
|
eventType = "review_changes_requested";
|
|
32
36
|
dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
|