patchrelay 0.35.3 → 0.35.5
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/migrations.js +2 -0
- package/dist/db.js +5 -0
- package/dist/merge-queue-protocol.js +3 -1
- package/dist/run-orchestrator.js +32 -11
- package/dist/webhook-handler.js +57 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/db/migrations.js
CHANGED
|
@@ -218,6 +218,8 @@ export function runPatchRelayMigrations(connection) {
|
|
|
218
218
|
addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
|
|
219
219
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
|
|
220
220
|
addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
|
|
221
|
+
// Track whether the merge queue label was successfully applied.
|
|
222
|
+
addColumnIfMissing(connection, "issues", "queue_label_applied", "INTEGER NOT NULL DEFAULT 0");
|
|
221
223
|
}
|
|
222
224
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
223
225
|
const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
|
package/dist/db.js
CHANGED
|
@@ -245,6 +245,10 @@ export class PatchRelayDatabase {
|
|
|
245
245
|
sets.push("last_zombie_recovery_at = @lastZombieRecoveryAt");
|
|
246
246
|
values.lastZombieRecoveryAt = params.lastZombieRecoveryAt;
|
|
247
247
|
}
|
|
248
|
+
if (params.queueLabelApplied !== undefined) {
|
|
249
|
+
sets.push("queue_label_applied = @queueLabelApplied");
|
|
250
|
+
values.queueLabelApplied = params.queueLabelApplied ? 1 : 0;
|
|
251
|
+
}
|
|
248
252
|
this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
|
|
249
253
|
}
|
|
250
254
|
else {
|
|
@@ -743,6 +747,7 @@ function mapIssueRow(row) {
|
|
|
743
747
|
reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
|
|
744
748
|
zombieRecoveryAttempts: Number(row.zombie_recovery_attempts ?? 0),
|
|
745
749
|
...(row.last_zombie_recovery_at !== null && row.last_zombie_recovery_at !== undefined ? { lastZombieRecoveryAt: String(row.last_zombie_recovery_at) } : {}),
|
|
750
|
+
queueLabelApplied: Boolean(row.queue_label_applied),
|
|
746
751
|
};
|
|
747
752
|
}
|
|
748
753
|
function mapRunRow(row) {
|
|
@@ -12,7 +12,7 @@ export function resolveMergeQueueProtocol(project) {
|
|
|
12
12
|
export async function requestMergeQueueAdmission(params) {
|
|
13
13
|
const { issue, protocol, logger, feed } = params;
|
|
14
14
|
if (!protocol.repoFullName || !issue.prNumber)
|
|
15
|
-
return;
|
|
15
|
+
return false;
|
|
16
16
|
feed?.publish({
|
|
17
17
|
level: "info",
|
|
18
18
|
kind: "github",
|
|
@@ -42,6 +42,7 @@ export async function requestMergeQueueAdmission(params) {
|
|
|
42
42
|
status: "queue_label_applied",
|
|
43
43
|
summary: `Queue label "${protocol.admissionLabel}" applied to PR #${issue.prNumber}`,
|
|
44
44
|
});
|
|
45
|
+
return true;
|
|
45
46
|
}
|
|
46
47
|
catch (error) {
|
|
47
48
|
logger.warn({ issueKey: issue.issueKey, err: error }, "Failed to add merge queue label");
|
|
@@ -55,5 +56,6 @@ export async function requestMergeQueueAdmission(params) {
|
|
|
55
56
|
summary: `Queue hand-off failed while adding label "${protocol.admissionLabel}" to PR #${issue.prNumber}`,
|
|
56
57
|
detail: error instanceof Error ? error.message : String(error),
|
|
57
58
|
});
|
|
59
|
+
return false;
|
|
58
60
|
}
|
|
59
61
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -703,6 +703,10 @@ export class RunOrchestrator {
|
|
|
703
703
|
if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
|
|
704
704
|
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
705
705
|
}
|
|
706
|
+
else if (!issue.queueLabelApplied) {
|
|
707
|
+
// Retry failed label application
|
|
708
|
+
await this.requestMergeQueueAdmission(issue, issue.projectId);
|
|
709
|
+
}
|
|
706
710
|
continue;
|
|
707
711
|
}
|
|
708
712
|
// Checks failed + idle — route based on durable GitHub failure provenance.
|
|
@@ -734,15 +738,29 @@ export class RunOrchestrator {
|
|
|
734
738
|
continue;
|
|
735
739
|
}
|
|
736
740
|
if (issue.factoryState === "awaiting_queue") {
|
|
737
|
-
|
|
738
|
-
this.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
741
|
+
// Infer provenance: check if steward eviction check run exists on the PR
|
|
742
|
+
const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
|
|
743
|
+
const inferProtocol = resolveMergeQueueProtocol(inferProject);
|
|
744
|
+
let inferred = "branch_ci";
|
|
745
|
+
if (inferProject?.github?.repoFullName && issue.prNumber && issue.lastGitHubFailureHeadSha) {
|
|
746
|
+
try {
|
|
747
|
+
const { stdout } = await execCommand("gh", [
|
|
748
|
+
"api",
|
|
749
|
+
`repos/${inferProject.github.repoFullName}/commits/${issue.lastGitHubFailureHeadSha}/check-runs`,
|
|
750
|
+
"--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
|
|
751
|
+
], { timeoutMs: 10_000 });
|
|
752
|
+
if (stdout.trim().length > 0)
|
|
753
|
+
inferred = "queue_eviction";
|
|
754
|
+
}
|
|
755
|
+
catch { /* best effort */ }
|
|
756
|
+
}
|
|
757
|
+
const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
|
|
758
|
+
const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
|
|
759
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
|
|
760
|
+
const pendingRunContext = buildFailureContext(issue);
|
|
761
|
+
this.advanceIdleIssue(issue, inferState, {
|
|
762
|
+
pendingRunType: inferRunType,
|
|
763
|
+
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
746
764
|
});
|
|
747
765
|
continue;
|
|
748
766
|
}
|
|
@@ -1117,15 +1135,18 @@ export class RunOrchestrator {
|
|
|
1117
1135
|
void this.syncLinearSession(escalatedIssue);
|
|
1118
1136
|
}
|
|
1119
1137
|
/** Add the merge queue admission label for external-queue projects (best-effort). */
|
|
1120
|
-
requestMergeQueueAdmission(issue, projectId) {
|
|
1138
|
+
async requestMergeQueueAdmission(issue, projectId) {
|
|
1121
1139
|
const project = this.config.projects.find((p) => p.id === projectId);
|
|
1122
1140
|
const protocol = resolveMergeQueueProtocol(project);
|
|
1123
|
-
|
|
1141
|
+
const applied = await requestMergeQueueAdmission({
|
|
1124
1142
|
issue,
|
|
1125
1143
|
protocol,
|
|
1126
1144
|
logger: this.logger,
|
|
1127
1145
|
feed: this.feed,
|
|
1128
1146
|
});
|
|
1147
|
+
if (applied) {
|
|
1148
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueLabelApplied: true });
|
|
1149
|
+
}
|
|
1129
1150
|
}
|
|
1130
1151
|
failRunAndClear(run, message, nextState = "failed") {
|
|
1131
1152
|
this.db.transaction(() => {
|
package/dist/webhook-handler.js
CHANGED
|
@@ -98,6 +98,40 @@ export class WebhookHandler {
|
|
|
98
98
|
const result = await this.recordDesiredStage(project, hydrated);
|
|
99
99
|
const trackedIssue = result.issue;
|
|
100
100
|
const newlyReadyDependents = this.reconcileDependentReadiness(project.id, issue.id);
|
|
101
|
+
// Handle issue removal: release active runs, mark as failed.
|
|
102
|
+
if (hydrated.triggerEvent === "issueRemoved" && trackedIssue) {
|
|
103
|
+
const removedIssue = this.db.getIssue(project.id, issue.id);
|
|
104
|
+
if (removedIssue?.activeRunId) {
|
|
105
|
+
const run = this.db.getRun(removedIssue.activeRunId);
|
|
106
|
+
if (run) {
|
|
107
|
+
this.db.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
|
|
108
|
+
}
|
|
109
|
+
this.db.upsertIssue({
|
|
110
|
+
projectId: project.id,
|
|
111
|
+
linearIssueId: issue.id,
|
|
112
|
+
activeRunId: null,
|
|
113
|
+
pendingRunType: null,
|
|
114
|
+
factoryState: "failed",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
|
|
118
|
+
this.db.upsertIssue({
|
|
119
|
+
projectId: project.id,
|
|
120
|
+
linearIssueId: issue.id,
|
|
121
|
+
pendingRunType: null,
|
|
122
|
+
factoryState: "failed",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
this.feed?.publish({
|
|
126
|
+
level: "warn",
|
|
127
|
+
kind: "stage",
|
|
128
|
+
issueKey: issue.identifier,
|
|
129
|
+
projectId: project.id,
|
|
130
|
+
stage: "failed",
|
|
131
|
+
status: "issue_removed",
|
|
132
|
+
summary: "Issue removed from Linear",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
101
135
|
// Handle agent session events
|
|
102
136
|
await this.handleAgentSession(hydrated, project, trackedIssue, result.desiredStage, result.delegated);
|
|
103
137
|
// Handle comments during active run
|
|
@@ -179,6 +213,17 @@ export class WebhookHandler {
|
|
|
179
213
|
clearPendingImplementation = Boolean(existingIssue.pendingRunType);
|
|
180
214
|
}
|
|
181
215
|
}
|
|
216
|
+
// Un-delegation: transition to awaiting_input unless past point of no return.
|
|
217
|
+
// awaiting_queue means the PR is approved and in the merge queue — let it merge.
|
|
218
|
+
let undelegatedFactoryState;
|
|
219
|
+
if (normalized.triggerEvent === "delegateChanged" && !delegated && existingIssue) {
|
|
220
|
+
const pastNoReturn = existingIssue.factoryState === "awaiting_queue"
|
|
221
|
+
|| TERMINAL_STATES.has(existingIssue.factoryState);
|
|
222
|
+
if (!pastNoReturn) {
|
|
223
|
+
undelegatedFactoryState = "awaiting_input";
|
|
224
|
+
clearPendingImplementation = Boolean(existingIssue.pendingRunType);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
182
227
|
// Resolve agent session
|
|
183
228
|
const agentSessionId = normalized.agentSession?.id ??
|
|
184
229
|
(!activeRun && (pendingRunType || (normalized.triggerEvent === "delegateChanged" && !delegated)) ? null : undefined);
|
|
@@ -201,11 +246,23 @@ export class WebhookHandler {
|
|
|
201
246
|
: {}),
|
|
202
247
|
...(agentSessionId !== undefined ? { agentSessionId } : {}),
|
|
203
248
|
...(clearActiveRun ? { activeRunId: null } : {}),
|
|
249
|
+
...(undelegatedFactoryState ? { factoryState: undelegatedFactoryState } : {}),
|
|
204
250
|
});
|
|
205
251
|
if (clearActiveRun && activeRun) {
|
|
206
252
|
const reason = terminalForAutomation ? "Issue reached terminal state during active run" : "Un-delegated from PatchRelay";
|
|
207
253
|
this.db.finishRun(activeRun.id, { status: "released", failureReason: reason });
|
|
208
254
|
}
|
|
255
|
+
if (undelegatedFactoryState) {
|
|
256
|
+
this.feed?.publish({
|
|
257
|
+
level: "warn",
|
|
258
|
+
kind: "stage",
|
|
259
|
+
issueKey: issue.issueKey,
|
|
260
|
+
projectId: project.id,
|
|
261
|
+
stage: "awaiting_input",
|
|
262
|
+
status: "un_delegated",
|
|
263
|
+
summary: "Issue un-delegated from PatchRelay",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
209
266
|
return {
|
|
210
267
|
issue: this.db.issueToTrackedIssue(issue),
|
|
211
268
|
desiredStage: pendingRunType,
|