patchrelay 0.35.7 → 0.35.9
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/github-app-token.js +2 -0
- package/dist/idle-reconciliation.js +262 -0
- package/dist/linear-session-sync.js +143 -0
- package/dist/queue-health-monitor.js +131 -0
- package/dist/run-orchestrator.js +45 -498
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/github-app-token.js
CHANGED
|
@@ -203,8 +203,10 @@ async function resolveBotIdentity(jwt) {
|
|
|
203
203
|
throw new Error(`Failed to fetch bot user ${botLogin} (${userResponse.status}): ${body}`);
|
|
204
204
|
}
|
|
205
205
|
const user = await userResponse.json();
|
|
206
|
+
const { tokenFile } = getGitHubAppPaths();
|
|
206
207
|
return {
|
|
207
208
|
name: user.login,
|
|
208
209
|
email: `${user.id}+${user.login}@users.noreply.github.com`,
|
|
210
|
+
tokenFile,
|
|
209
211
|
};
|
|
210
212
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
|
+
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
3
|
+
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
4
|
+
import { execCommand } from "./utils.js";
|
|
5
|
+
function isDuplicateRepairAttempt(issue, context) {
|
|
6
|
+
const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
|
|
7
|
+
const headSha = typeof context?.failureHeadSha === "string"
|
|
8
|
+
? context.failureHeadSha
|
|
9
|
+
: typeof context?.headSha === "string" ? context.headSha : undefined;
|
|
10
|
+
if (!signature)
|
|
11
|
+
return false;
|
|
12
|
+
return issue.lastAttemptedFailureSignature === signature
|
|
13
|
+
&& (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
|
|
14
|
+
}
|
|
15
|
+
function buildFailureContext(issue) {
|
|
16
|
+
const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
|
|
17
|
+
const queueRepairContext = issue.lastQueueIncidentJson
|
|
18
|
+
? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
|
|
19
|
+
: undefined;
|
|
20
|
+
if (!queueRepairContext
|
|
21
|
+
&& !issue.lastGitHubFailureSource
|
|
22
|
+
&& !issue.lastGitHubFailureHeadSha
|
|
23
|
+
&& !issue.lastGitHubFailureSignature
|
|
24
|
+
&& !issue.lastGitHubFailureCheckName
|
|
25
|
+
&& !issue.lastGitHubFailureCheckUrl
|
|
26
|
+
&& !storedFailureContext) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
|
|
31
|
+
...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
|
|
32
|
+
...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
|
|
33
|
+
...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
|
|
34
|
+
...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
35
|
+
...(storedFailureContext ? storedFailureContext : {}),
|
|
36
|
+
...(queueRepairContext ? queueRepairContext : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
|
|
40
|
+
if (pendingRunType)
|
|
41
|
+
return "patchrelay";
|
|
42
|
+
if (newState === "awaiting_queue")
|
|
43
|
+
return "merge_steward";
|
|
44
|
+
if (newState === "repairing_ci" || newState === "repairing_queue")
|
|
45
|
+
return "patchrelay";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
export class IdleIssueReconciler {
|
|
49
|
+
db;
|
|
50
|
+
config;
|
|
51
|
+
deps;
|
|
52
|
+
logger;
|
|
53
|
+
feed;
|
|
54
|
+
constructor(db, config, deps, logger, feed) {
|
|
55
|
+
this.db = db;
|
|
56
|
+
this.config = config;
|
|
57
|
+
this.deps = deps;
|
|
58
|
+
this.logger = logger;
|
|
59
|
+
this.feed = feed;
|
|
60
|
+
}
|
|
61
|
+
async reconcile() {
|
|
62
|
+
for (const issue of this.db.listIdleNonTerminalIssues()) {
|
|
63
|
+
if (issue.prState === "merged") {
|
|
64
|
+
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
68
|
+
if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
|
|
69
|
+
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
70
|
+
}
|
|
71
|
+
else if (!issue.queueLabelApplied) {
|
|
72
|
+
await this.deps.requestMergeQueueAdmission(issue, issue.projectId);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (issue.prCheckStatus === "failed") {
|
|
77
|
+
await this.routeFailedIssue(issue);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Probe GitHub for stale pr_open issues: detect missed reviews,
|
|
81
|
+
// merge conflicts, and other state that webhooks may have missed.
|
|
82
|
+
if (issue.factoryState === "pr_open") {
|
|
83
|
+
await this.reconcileFromGitHub(issue);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const issue of this.db.listBlockedDelegatedIssues()) {
|
|
87
|
+
const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
88
|
+
if (unresolved === 0) {
|
|
89
|
+
this.db.upsertIssue({
|
|
90
|
+
projectId: issue.projectId,
|
|
91
|
+
linearIssueId: issue.linearIssueId,
|
|
92
|
+
pendingRunType: "implementation",
|
|
93
|
+
});
|
|
94
|
+
this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
advanceIdleIssue(issue, newState, options) {
|
|
99
|
+
if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
|
|
103
|
+
const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
|
|
104
|
+
this.db.upsertIssue({
|
|
105
|
+
projectId: issue.projectId,
|
|
106
|
+
linearIssueId: issue.linearIssueId,
|
|
107
|
+
factoryState: newState,
|
|
108
|
+
...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
|
|
109
|
+
...(options?.pendingRunType
|
|
110
|
+
? {
|
|
111
|
+
pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
...(resetQueueLabel ? { queueLabelApplied: false } : {}),
|
|
115
|
+
...(options?.clearFailureProvenance
|
|
116
|
+
? {
|
|
117
|
+
lastGitHubFailureSource: null,
|
|
118
|
+
lastGitHubFailureHeadSha: null,
|
|
119
|
+
lastGitHubFailureSignature: null,
|
|
120
|
+
lastGitHubFailureCheckName: null,
|
|
121
|
+
lastGitHubFailureCheckUrl: null,
|
|
122
|
+
lastGitHubFailureContextJson: null,
|
|
123
|
+
lastGitHubFailureAt: null,
|
|
124
|
+
lastQueueIncidentJson: null,
|
|
125
|
+
lastAttemptedFailureHeadSha: null,
|
|
126
|
+
lastAttemptedFailureSignature: null,
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
130
|
+
const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
|
|
131
|
+
if (branchOwner) {
|
|
132
|
+
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
133
|
+
}
|
|
134
|
+
this.feed?.publish({
|
|
135
|
+
level: "info",
|
|
136
|
+
kind: "stage",
|
|
137
|
+
issueKey: issue.issueKey,
|
|
138
|
+
projectId: issue.projectId,
|
|
139
|
+
stage: newState,
|
|
140
|
+
status: "reconciled",
|
|
141
|
+
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
142
|
+
});
|
|
143
|
+
if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
|
|
144
|
+
void this.deps.requestMergeQueueAdmission(issue, issue.projectId);
|
|
145
|
+
}
|
|
146
|
+
if (options?.pendingRunType) {
|
|
147
|
+
this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async routeFailedIssue(issue) {
|
|
151
|
+
if (issue.lastGitHubFailureSource === "queue_eviction") {
|
|
152
|
+
const pendingRunContext = buildFailureContext(issue);
|
|
153
|
+
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
154
|
+
this.advanceIdleIssue(issue, "repairing_queue");
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.advanceIdleIssue(issue, "repairing_queue", {
|
|
158
|
+
pendingRunType: "queue_repair",
|
|
159
|
+
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (issue.lastGitHubFailureSource === "branch_ci") {
|
|
165
|
+
const pendingRunContext = buildFailureContext(issue);
|
|
166
|
+
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
167
|
+
this.advanceIdleIssue(issue, "repairing_ci");
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
this.advanceIdleIssue(issue, "repairing_ci", {
|
|
171
|
+
pendingRunType: "ci_repair",
|
|
172
|
+
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (issue.factoryState === "awaiting_queue") {
|
|
178
|
+
const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
|
|
179
|
+
const inferProtocol = resolveMergeQueueProtocol(inferProject);
|
|
180
|
+
let inferred = "branch_ci";
|
|
181
|
+
const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
|
|
182
|
+
if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
|
|
183
|
+
try {
|
|
184
|
+
const { stdout } = await execCommand("gh", [
|
|
185
|
+
"api",
|
|
186
|
+
`repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
|
|
187
|
+
"--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
|
|
188
|
+
], { timeoutMs: 10_000 });
|
|
189
|
+
if (stdout.trim().length > 0)
|
|
190
|
+
inferred = "queue_eviction";
|
|
191
|
+
}
|
|
192
|
+
catch { /* best effort */ }
|
|
193
|
+
}
|
|
194
|
+
const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
|
|
195
|
+
const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
|
|
196
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
|
|
197
|
+
const pendingRunContext = buildFailureContext(issue);
|
|
198
|
+
this.advanceIdleIssue(issue, inferState, {
|
|
199
|
+
pendingRunType: inferRunType,
|
|
200
|
+
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const pendingRunContext = buildFailureContext(issue);
|
|
205
|
+
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
206
|
+
this.advanceIdleIssue(issue, "repairing_ci");
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.advanceIdleIssue(issue, "repairing_ci", {
|
|
210
|
+
pendingRunType: "ci_repair",
|
|
211
|
+
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async reconcileFromGitHub(issue) {
|
|
216
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
217
|
+
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
const { stdout } = await execCommand("gh", [
|
|
221
|
+
"pr", "view", String(issue.prNumber),
|
|
222
|
+
"--repo", project.github.repoFullName,
|
|
223
|
+
"--json", "state,reviewDecision,mergeable,mergeStateStatus",
|
|
224
|
+
], { timeoutMs: 10_000 });
|
|
225
|
+
const pr = JSON.parse(stdout);
|
|
226
|
+
if (pr.state === "MERGED") {
|
|
227
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
228
|
+
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (pr.reviewDecision === "APPROVED") {
|
|
232
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
233
|
+
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Merge conflict detected — dispatch a repair run to rebase the branch.
|
|
237
|
+
if (pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") {
|
|
238
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR has merge conflicts, dispatching rebase");
|
|
239
|
+
this.advanceIdleIssue(issue, "repairing_queue", {
|
|
240
|
+
pendingRunType: "queue_repair",
|
|
241
|
+
pendingRunContext: {
|
|
242
|
+
source: "idle_reconciliation",
|
|
243
|
+
failureReason: "merge_conflict_detected",
|
|
244
|
+
failureSignature: `conflict:${issue.prNumber}`,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
this.feed?.publish({
|
|
248
|
+
level: "warn",
|
|
249
|
+
kind: "github",
|
|
250
|
+
issueKey: issue.issueKey,
|
|
251
|
+
projectId: issue.projectId,
|
|
252
|
+
stage: "repairing_queue",
|
|
253
|
+
status: "conflict_detected",
|
|
254
|
+
summary: `PR #${issue.prNumber} has merge conflicts with main, dispatching rebase`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
2
|
+
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
|
+
const PROGRESS_THROTTLE_MS = 5_000;
|
|
4
|
+
export class LinearSessionSync {
|
|
5
|
+
config;
|
|
6
|
+
db;
|
|
7
|
+
linearProvider;
|
|
8
|
+
logger;
|
|
9
|
+
feed;
|
|
10
|
+
progressThrottle = new Map();
|
|
11
|
+
constructor(config, db, linearProvider, logger, feed) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.linearProvider = linearProvider;
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
this.feed = feed;
|
|
17
|
+
}
|
|
18
|
+
async emitActivity(issue, content, options) {
|
|
19
|
+
if (!issue.agentSessionId)
|
|
20
|
+
return;
|
|
21
|
+
try {
|
|
22
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
23
|
+
if (!linear)
|
|
24
|
+
return;
|
|
25
|
+
const allowEphemeral = content.type === "thought" || content.type === "action";
|
|
26
|
+
await linear.createAgentActivity({
|
|
27
|
+
agentSessionId: issue.agentSessionId,
|
|
28
|
+
content,
|
|
29
|
+
...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
34
|
+
this.logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
|
|
35
|
+
this.feed?.publish({
|
|
36
|
+
level: "warn",
|
|
37
|
+
kind: "linear",
|
|
38
|
+
issueKey: issue.issueKey,
|
|
39
|
+
projectId: issue.projectId,
|
|
40
|
+
status: "linear_error",
|
|
41
|
+
summary: `Linear activity failed: ${msg}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async syncSession(issue, options) {
|
|
46
|
+
if (!issue.agentSessionId)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
50
|
+
if (!linear?.updateAgentSession)
|
|
51
|
+
return;
|
|
52
|
+
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
53
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
54
|
+
...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
|
|
55
|
+
});
|
|
56
|
+
await linear.updateAgentSession({
|
|
57
|
+
agentSessionId: issue.agentSessionId,
|
|
58
|
+
plan: buildAgentSessionPlanForIssue(issue, options),
|
|
59
|
+
...(externalUrls ? { externalUrls } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
64
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async syncCodexPlan(issue, params) {
|
|
68
|
+
if (!issue.agentSessionId)
|
|
69
|
+
return;
|
|
70
|
+
const plan = params.plan;
|
|
71
|
+
if (!Array.isArray(plan))
|
|
72
|
+
return;
|
|
73
|
+
const STATUS_MAP = {
|
|
74
|
+
pending: "pending",
|
|
75
|
+
inProgress: "inProgress",
|
|
76
|
+
completed: "completed",
|
|
77
|
+
};
|
|
78
|
+
const steps = plan.map((entry) => {
|
|
79
|
+
const e = entry;
|
|
80
|
+
const step = typeof e.step === "string" ? e.step : String(e.step ?? "");
|
|
81
|
+
const status = typeof e.status === "string" ? (STATUS_MAP[e.status] ?? "pending") : "pending";
|
|
82
|
+
return { content: step, status };
|
|
83
|
+
});
|
|
84
|
+
const fullPlan = [
|
|
85
|
+
{ content: "Prepare workspace", status: "completed" },
|
|
86
|
+
...steps,
|
|
87
|
+
{ content: "Merge", status: "pending" },
|
|
88
|
+
];
|
|
89
|
+
try {
|
|
90
|
+
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
91
|
+
if (!linear?.updateAgentSession)
|
|
92
|
+
return;
|
|
93
|
+
await linear.updateAgentSession({
|
|
94
|
+
agentSessionId: issue.agentSessionId,
|
|
95
|
+
plan: fullPlan,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
100
|
+
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
maybeEmitProgress(notification, run) {
|
|
104
|
+
const activity = resolveProgressActivity(notification);
|
|
105
|
+
if (!activity)
|
|
106
|
+
return;
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const lastEmit = this.progressThrottle.get(run.id) ?? 0;
|
|
109
|
+
if (now - lastEmit < PROGRESS_THROTTLE_MS)
|
|
110
|
+
return;
|
|
111
|
+
this.progressThrottle.set(run.id, now);
|
|
112
|
+
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
113
|
+
if (issue) {
|
|
114
|
+
void this.emitActivity(issue, activity, { ephemeral: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
clearProgress(runId) {
|
|
118
|
+
this.progressThrottle.delete(runId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function resolveProgressActivity(notification) {
|
|
122
|
+
if (notification.method === "item/started") {
|
|
123
|
+
const item = notification.params.item;
|
|
124
|
+
if (!item)
|
|
125
|
+
return undefined;
|
|
126
|
+
const type = typeof item.type === "string" ? item.type : undefined;
|
|
127
|
+
if (type === "commandExecution") {
|
|
128
|
+
const cmd = item.command;
|
|
129
|
+
const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
|
|
130
|
+
return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
|
|
131
|
+
}
|
|
132
|
+
if (type === "mcpToolCall") {
|
|
133
|
+
const server = typeof item.server === "string" ? item.server : "";
|
|
134
|
+
const tool = typeof item.tool === "string" ? item.tool : "";
|
|
135
|
+
return { type: "action", action: "Using", parameter: `${server}/${tool}` };
|
|
136
|
+
}
|
|
137
|
+
if (type === "dynamicToolCall") {
|
|
138
|
+
const tool = typeof item.tool === "string" ? item.tool : "tool";
|
|
139
|
+
return { type: "action", action: "Using", parameter: tool };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
|
+
import { execCommand } from "./utils.js";
|
|
3
|
+
const QUEUE_HEALTH_GRACE_MS = 120_000;
|
|
4
|
+
const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
|
|
5
|
+
function isDuplicateProbe(issue, context) {
|
|
6
|
+
const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
|
|
7
|
+
const headSha = typeof context?.failureHeadSha === "string" ? context.failureHeadSha : undefined;
|
|
8
|
+
if (!signature)
|
|
9
|
+
return false;
|
|
10
|
+
return issue.lastAttemptedFailureSignature === signature
|
|
11
|
+
&& (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
|
|
12
|
+
}
|
|
13
|
+
export class QueueHealthMonitor {
|
|
14
|
+
db;
|
|
15
|
+
config;
|
|
16
|
+
advancer;
|
|
17
|
+
logger;
|
|
18
|
+
feed;
|
|
19
|
+
probeFailureFeedTimes = new Map();
|
|
20
|
+
constructor(db, config, advancer, logger, feed) {
|
|
21
|
+
this.db = db;
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.advancer = advancer;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
this.feed = feed;
|
|
26
|
+
}
|
|
27
|
+
async reconcile() {
|
|
28
|
+
for (const issue of this.db.listAwaitingQueueIssues()) {
|
|
29
|
+
await this.probeQueuedIssue(issue);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async probeQueuedIssue(issue) {
|
|
33
|
+
if (!issue.prNumber)
|
|
34
|
+
return;
|
|
35
|
+
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
36
|
+
if (!project?.github?.repoFullName)
|
|
37
|
+
return;
|
|
38
|
+
const age = Date.now() - Date.parse(issue.updatedAt);
|
|
39
|
+
if (age < QUEUE_HEALTH_GRACE_MS)
|
|
40
|
+
return;
|
|
41
|
+
const protocol = resolveMergeQueueProtocol(project);
|
|
42
|
+
let pr;
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execCommand("gh", [
|
|
45
|
+
"pr", "view", String(issue.prNumber),
|
|
46
|
+
"--repo", project.github.repoFullName,
|
|
47
|
+
"--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
|
|
48
|
+
], { timeoutMs: 10_000 });
|
|
49
|
+
pr = JSON.parse(stdout);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: error instanceof Error ? error.message : String(error) }, "Queue health: failed to probe GitHub PR state");
|
|
53
|
+
const issueKey = `${issue.projectId}::${issue.linearIssueId}`;
|
|
54
|
+
const lastFeedAt = this.probeFailureFeedTimes.get(issueKey) ?? 0;
|
|
55
|
+
if (Date.now() - lastFeedAt >= QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS) {
|
|
56
|
+
this.probeFailureFeedTimes.set(issueKey, Date.now());
|
|
57
|
+
this.feed?.publish({
|
|
58
|
+
level: "info",
|
|
59
|
+
kind: "github",
|
|
60
|
+
issueKey: issue.issueKey,
|
|
61
|
+
projectId: issue.projectId,
|
|
62
|
+
stage: "awaiting_queue",
|
|
63
|
+
status: "queue_health_probe_failed",
|
|
64
|
+
summary: `Queue health: failed to probe PR #${issue.prNumber}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
|
|
70
|
+
if (pr.state === "MERGED") {
|
|
71
|
+
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
72
|
+
this.advancer.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (pr.state !== "OPEN")
|
|
76
|
+
return;
|
|
77
|
+
const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
|
|
78
|
+
if (!hasQueueLabel)
|
|
79
|
+
return;
|
|
80
|
+
const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
|
|
81
|
+
let hasEvictionCheckRun = false;
|
|
82
|
+
if (!isDirty) {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout: checksOut } = await execCommand("gh", [
|
|
85
|
+
"api", `repos/${project.github.repoFullName}/commits/${pr.headRefOid}/check-runs`,
|
|
86
|
+
"--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
|
|
87
|
+
], { timeoutMs: 10_000 });
|
|
88
|
+
hasEvictionCheckRun = checksOut.trim().length > 0;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Best-effort check.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (isDirty || hasEvictionCheckRun) {
|
|
95
|
+
const headRefOid = pr.headRefOid ?? "unknown";
|
|
96
|
+
const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
|
|
97
|
+
const signature = `preemptive_queue_conflict:${headRefOid}`;
|
|
98
|
+
const pendingRunContext = {
|
|
99
|
+
source: "queue_health_monitor",
|
|
100
|
+
failureReason: reason,
|
|
101
|
+
failureHeadSha: headRefOid,
|
|
102
|
+
failureSignature: signature,
|
|
103
|
+
};
|
|
104
|
+
if (isDuplicateProbe(issue, pendingRunContext)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.db.upsertIssue({
|
|
108
|
+
projectId: issue.projectId,
|
|
109
|
+
linearIssueId: issue.linearIssueId,
|
|
110
|
+
lastAttemptedFailureHeadSha: headRefOid,
|
|
111
|
+
lastAttemptedFailureSignature: signature,
|
|
112
|
+
});
|
|
113
|
+
this.advancer.advanceIdleIssue(issue, "repairing_queue", {
|
|
114
|
+
pendingRunType: "queue_repair",
|
|
115
|
+
pendingRunContext,
|
|
116
|
+
});
|
|
117
|
+
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
118
|
+
this.feed?.publish({
|
|
119
|
+
level: "warn",
|
|
120
|
+
kind: "github",
|
|
121
|
+
issueKey: issue.issueKey,
|
|
122
|
+
projectId: issue.projectId,
|
|
123
|
+
stage: "repairing_queue",
|
|
124
|
+
status: hasEvictionCheckRun ? "queue_health_eviction_detected" : "queue_health_conflict_detected",
|
|
125
|
+
summary: hasEvictionCheckRun
|
|
126
|
+
? `Queue health: missed eviction detected on PR #${issue.prNumber}, dispatching repair`
|
|
127
|
+
: `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
|
|
4
|
-
import { parseGitHubFailureContext } from "./github-failure-context.js";
|
|
5
4
|
import { buildHookEnv, runProjectHook } from "./hook-runner.js";
|
|
6
|
-
import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
|
|
7
5
|
import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
|
|
8
6
|
import { buildRunCompletedActivity, buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
|
|
9
7
|
import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
|
|
10
|
-
import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
|
|
11
|
-
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
12
8
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
13
9
|
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
14
10
|
import { execCommand } from "./utils.js";
|
|
@@ -17,12 +13,9 @@ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
|
17
13
|
const DEFAULT_REVIEW_FIX_BUDGET = 3;
|
|
18
14
|
const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
|
|
19
15
|
const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const QUEUE_HEALTH_GRACE_MS = 120_000;
|
|
24
|
-
// Suppress repeated probe-failure feed events — at most one per issue per window.
|
|
25
|
-
const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000; // 5 minutes
|
|
16
|
+
import { QueueHealthMonitor } from "./queue-health-monitor.js";
|
|
17
|
+
import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
|
|
18
|
+
import { LinearSessionSync } from "./linear-session-sync.js";
|
|
26
19
|
function slugify(value) {
|
|
27
20
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
28
21
|
}
|
|
@@ -93,7 +86,6 @@ function buildRunPrompt(issue, runType, repoPath, context) {
|
|
|
93
86
|
}
|
|
94
87
|
return lines.join("\n");
|
|
95
88
|
}
|
|
96
|
-
const PROGRESS_THROTTLE_MS = 10_000;
|
|
97
89
|
export class RunOrchestrator {
|
|
98
90
|
config;
|
|
99
91
|
db;
|
|
@@ -103,9 +95,10 @@ export class RunOrchestrator {
|
|
|
103
95
|
logger;
|
|
104
96
|
feed;
|
|
105
97
|
worktreeManager;
|
|
106
|
-
progressThrottle = new Map();
|
|
107
98
|
/** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
|
|
108
|
-
|
|
99
|
+
queueHealthMonitor;
|
|
100
|
+
idleReconciler;
|
|
101
|
+
linearSync;
|
|
109
102
|
activeThreadId;
|
|
110
103
|
botIdentity;
|
|
111
104
|
constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
|
|
@@ -117,6 +110,14 @@ export class RunOrchestrator {
|
|
|
117
110
|
this.logger = logger;
|
|
118
111
|
this.feed = feed;
|
|
119
112
|
this.worktreeManager = new WorktreeManager(config);
|
|
113
|
+
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
114
|
+
this.idleReconciler = new IdleIssueReconciler(db, config, {
|
|
115
|
+
requestMergeQueueAdmission: (issue, projectId) => this.requestMergeQueueAdmission(issue, projectId),
|
|
116
|
+
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
117
|
+
}, logger, feed);
|
|
118
|
+
this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
|
|
119
|
+
advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
|
|
120
|
+
}, logger, feed);
|
|
120
121
|
}
|
|
121
122
|
// ─── Run ────────────────────────────────────────────────────────
|
|
122
123
|
async run(item) {
|
|
@@ -219,11 +220,17 @@ export class RunOrchestrator {
|
|
|
219
220
|
try {
|
|
220
221
|
// Ensure worktree
|
|
221
222
|
await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
|
|
222
|
-
// Set bot git identity when GitHub App is configured
|
|
223
|
+
// Set bot git identity and push credentials when GitHub App is configured.
|
|
224
|
+
// This ensures commits are authored by and pushes are authenticated as
|
|
225
|
+
// patchrelay[bot], not the system user.
|
|
223
226
|
if (this.botIdentity) {
|
|
224
227
|
const gitBin = this.config.runner.gitBin;
|
|
225
228
|
await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
|
|
226
229
|
await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
|
|
230
|
+
// Override credential helper to use the App installation token for git push.
|
|
231
|
+
// The helper script reads the token file and returns it as the password.
|
|
232
|
+
const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=$(cat ${this.botIdentity.tokenFile})"; }; f`;
|
|
233
|
+
await execCommand(gitBin, ["-C", worktreePath, "config", "credential.helper", credentialHelper], { timeoutMs: 5_000 });
|
|
227
234
|
}
|
|
228
235
|
// Freshen the worktree: fetch + rebase onto latest base branch.
|
|
229
236
|
// This prevents branch contamination when local main has drifted
|
|
@@ -279,8 +286,8 @@ export class RunOrchestrator {
|
|
|
279
286
|
});
|
|
280
287
|
this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
|
|
281
288
|
const failedIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
|
|
282
|
-
void this.
|
|
283
|
-
void this.
|
|
289
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
|
|
290
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: runType });
|
|
284
291
|
throw error;
|
|
285
292
|
}
|
|
286
293
|
this.db.updateRunThread(run.id, { threadId, turnId });
|
|
@@ -296,8 +303,8 @@ export class RunOrchestrator {
|
|
|
296
303
|
this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
|
|
297
304
|
// Emit Linear activity + plan
|
|
298
305
|
const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
|
|
299
|
-
void this.
|
|
300
|
-
void this.
|
|
306
|
+
void this.linearSync.emitActivity(freshIssue, buildRunStartedActivity(runType));
|
|
307
|
+
void this.linearSync.syncSession(freshIssue, { activeRunType: runType });
|
|
301
308
|
}
|
|
302
309
|
// ─── Pre-run branch freshening ────────────────────────────────────
|
|
303
310
|
/**
|
|
@@ -378,12 +385,12 @@ export class RunOrchestrator {
|
|
|
378
385
|
});
|
|
379
386
|
}
|
|
380
387
|
// Emit ephemeral progress activity to Linear for notable in-flight events
|
|
381
|
-
this.
|
|
388
|
+
this.linearSync.maybeEmitProgress(notification, run);
|
|
382
389
|
// Sync codex plan to Linear session when it updates
|
|
383
390
|
if (notification.method === "turn/plan/updated") {
|
|
384
391
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
385
392
|
if (issue) {
|
|
386
|
-
void this.
|
|
393
|
+
void this.linearSync.syncCodexPlan(issue, notification.params);
|
|
387
394
|
}
|
|
388
395
|
}
|
|
389
396
|
if (notification.method !== "turn/completed")
|
|
@@ -417,9 +424,9 @@ export class RunOrchestrator {
|
|
|
417
424
|
summary: `Turn failed for ${run.runType}`,
|
|
418
425
|
});
|
|
419
426
|
const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
420
|
-
void this.
|
|
421
|
-
void this.
|
|
422
|
-
this.
|
|
427
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType));
|
|
428
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
429
|
+
this.linearSync.clearProgress(run.id);
|
|
423
430
|
this.activeThreadId = undefined;
|
|
424
431
|
return;
|
|
425
432
|
}
|
|
@@ -442,9 +449,9 @@ export class RunOrchestrator {
|
|
|
442
449
|
status: "branch_not_advanced",
|
|
443
450
|
summary: verifiedRepairError,
|
|
444
451
|
});
|
|
445
|
-
void this.
|
|
446
|
-
void this.
|
|
447
|
-
this.
|
|
452
|
+
void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
|
|
453
|
+
void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
|
|
454
|
+
this.linearSync.clearProgress(run.id);
|
|
448
455
|
this.activeThreadId = undefined;
|
|
449
456
|
return;
|
|
450
457
|
}
|
|
@@ -499,54 +506,16 @@ export class RunOrchestrator {
|
|
|
499
506
|
// Emit Linear completion activity + plan
|
|
500
507
|
const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
501
508
|
const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
|
|
502
|
-
void this.
|
|
509
|
+
void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
|
|
503
510
|
runType: run.runType,
|
|
504
511
|
completionSummary,
|
|
505
512
|
postRunState: updatedIssue.factoryState,
|
|
506
513
|
...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
|
|
507
514
|
}));
|
|
508
|
-
void this.
|
|
509
|
-
this.
|
|
515
|
+
void this.linearSync.syncSession(updatedIssue);
|
|
516
|
+
this.linearSync.clearProgress(run.id);
|
|
510
517
|
this.activeThreadId = undefined;
|
|
511
518
|
}
|
|
512
|
-
// ─── In-flight progress ──────────────────────────────────────────
|
|
513
|
-
maybeEmitProgressActivity(notification, run) {
|
|
514
|
-
const activity = this.resolveProgressActivity(notification);
|
|
515
|
-
if (!activity)
|
|
516
|
-
return;
|
|
517
|
-
const now = Date.now();
|
|
518
|
-
const lastEmit = this.progressThrottle.get(run.id) ?? 0;
|
|
519
|
-
if (now - lastEmit < PROGRESS_THROTTLE_MS)
|
|
520
|
-
return;
|
|
521
|
-
this.progressThrottle.set(run.id, now);
|
|
522
|
-
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
523
|
-
if (issue) {
|
|
524
|
-
void this.emitLinearActivity(issue, activity, { ephemeral: true });
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
resolveProgressActivity(notification) {
|
|
528
|
-
if (notification.method === "item/started") {
|
|
529
|
-
const item = notification.params.item;
|
|
530
|
-
if (!item)
|
|
531
|
-
return undefined;
|
|
532
|
-
const type = typeof item.type === "string" ? item.type : undefined;
|
|
533
|
-
if (type === "commandExecution") {
|
|
534
|
-
const cmd = item.command;
|
|
535
|
-
const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
|
|
536
|
-
return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
|
|
537
|
-
}
|
|
538
|
-
if (type === "mcpToolCall") {
|
|
539
|
-
const server = typeof item.server === "string" ? item.server : "";
|
|
540
|
-
const tool = typeof item.tool === "string" ? item.tool : "";
|
|
541
|
-
return { type: "action", action: "Using", parameter: `${server}/${tool}` };
|
|
542
|
-
}
|
|
543
|
-
if (type === "dynamicToolCall") {
|
|
544
|
-
const tool = typeof item.tool === "string" ? item.tool : "tool";
|
|
545
|
-
return { type: "action", action: "Using", parameter: tool };
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
return undefined;
|
|
549
|
-
}
|
|
550
519
|
// ─── Active status for query ──────────────────────────────────────
|
|
551
520
|
async getActiveRunStatus(issueKey) {
|
|
552
521
|
const issue = this.db.getIssueByKey(issueKey);
|
|
@@ -570,309 +539,14 @@ export class RunOrchestrator {
|
|
|
570
539
|
}
|
|
571
540
|
// Preemptively detect stuck merge-queue PRs (conflicts visible on
|
|
572
541
|
// GitHub) and dispatch queue_repair before the Steward evicts.
|
|
573
|
-
await this.
|
|
542
|
+
await this.queueHealthMonitor.reconcile();
|
|
574
543
|
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
575
544
|
// shows they should transition (e.g. approved PR, missed webhook).
|
|
576
|
-
await this.
|
|
577
|
-
}
|
|
578
|
-
// ─── Queue Health Monitor ──────────────────────────────────────────
|
|
579
|
-
async reconcileQueueHealth() {
|
|
580
|
-
for (const issue of this.db.listAwaitingQueueIssues()) {
|
|
581
|
-
await this.probeQueuedIssue(issue);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
async probeQueuedIssue(issue) {
|
|
585
|
-
if (!issue.prNumber)
|
|
586
|
-
return;
|
|
587
|
-
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
588
|
-
if (!project?.github?.repoFullName)
|
|
589
|
-
return;
|
|
590
|
-
// Grace period — don't probe PRs that just entered the queue.
|
|
591
|
-
const age = Date.now() - Date.parse(issue.updatedAt);
|
|
592
|
-
if (age < QUEUE_HEALTH_GRACE_MS)
|
|
593
|
-
return;
|
|
594
|
-
const protocol = resolveMergeQueueProtocol(project);
|
|
595
|
-
let pr;
|
|
596
|
-
try {
|
|
597
|
-
const { stdout } = await execCommand("gh", [
|
|
598
|
-
"pr", "view", String(issue.prNumber),
|
|
599
|
-
"--repo", project.github.repoFullName,
|
|
600
|
-
"--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
|
|
601
|
-
], { timeoutMs: 10_000 });
|
|
602
|
-
pr = JSON.parse(stdout);
|
|
603
|
-
}
|
|
604
|
-
catch (error) {
|
|
605
|
-
this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: error instanceof Error ? error.message : String(error) }, "Queue health: failed to probe GitHub PR state");
|
|
606
|
-
// Throttle feed events — at most one per issue per cooldown window.
|
|
607
|
-
const issueKey = `${issue.projectId}::${issue.linearIssueId}`;
|
|
608
|
-
const lastFeedAt = this.probeFailureFeedTimes.get(issueKey) ?? 0;
|
|
609
|
-
if (Date.now() - lastFeedAt >= QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS) {
|
|
610
|
-
this.probeFailureFeedTimes.set(issueKey, Date.now());
|
|
611
|
-
this.feed?.publish({
|
|
612
|
-
level: "info",
|
|
613
|
-
kind: "github",
|
|
614
|
-
issueKey: issue.issueKey,
|
|
615
|
-
projectId: issue.projectId,
|
|
616
|
-
stage: "awaiting_queue",
|
|
617
|
-
status: "queue_health_probe_failed",
|
|
618
|
-
summary: `Queue health: failed to probe PR #${issue.prNumber}`,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
// Successful probe — clear any probe-failure throttle for this issue.
|
|
624
|
-
this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
|
|
625
|
-
// Missed merge webhook — advance to done.
|
|
626
|
-
if (pr.state === "MERGED") {
|
|
627
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
628
|
-
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
// Non-open PRs (closed, draft) — don't enter repair logic.
|
|
632
|
-
if (pr.state !== "OPEN")
|
|
633
|
-
return;
|
|
634
|
-
// Verify admission label is still present — if the Steward removed it
|
|
635
|
-
// (eviction, dequeue) but PatchRelay missed the webhook, we should not
|
|
636
|
-
// treat a DIRTY PR as a queue-health problem.
|
|
637
|
-
const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
|
|
638
|
-
if (!hasQueueLabel)
|
|
639
|
-
return;
|
|
640
|
-
// Detect queue issues: either GitHub reports DIRTY, or the steward
|
|
641
|
-
// eviction check run failed (webhook may have been missed).
|
|
642
|
-
const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
|
|
643
|
-
let hasEvictionCheckRun = false;
|
|
644
|
-
if (!isDirty) {
|
|
645
|
-
// Check for missed eviction webhook by looking for the steward's
|
|
646
|
-
// check run on the PR head.
|
|
647
|
-
try {
|
|
648
|
-
const { stdout: checksOut } = await execCommand("gh", [
|
|
649
|
-
"api", `repos/${project.github.repoFullName}/commits/${pr.headRefOid}/check-runs`,
|
|
650
|
-
"--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
|
|
651
|
-
], { timeoutMs: 10_000 });
|
|
652
|
-
hasEvictionCheckRun = checksOut.trim().length > 0;
|
|
653
|
-
}
|
|
654
|
-
catch {
|
|
655
|
-
// Best-effort check.
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (isDirty || hasEvictionCheckRun) {
|
|
659
|
-
const headRefOid = pr.headRefOid ?? "unknown";
|
|
660
|
-
const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
|
|
661
|
-
const signature = `preemptive_queue_conflict:${headRefOid}`;
|
|
662
|
-
const pendingRunContext = {
|
|
663
|
-
source: "queue_health_monitor",
|
|
664
|
-
failureReason: reason,
|
|
665
|
-
failureHeadSha: headRefOid,
|
|
666
|
-
failureSignature: signature,
|
|
667
|
-
};
|
|
668
|
-
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
this.db.upsertIssue({
|
|
672
|
-
projectId: issue.projectId,
|
|
673
|
-
linearIssueId: issue.linearIssueId,
|
|
674
|
-
lastAttemptedFailureHeadSha: headRefOid,
|
|
675
|
-
lastAttemptedFailureSignature: signature,
|
|
676
|
-
});
|
|
677
|
-
this.advanceIdleIssue(issue, "repairing_queue", {
|
|
678
|
-
pendingRunType: "queue_repair",
|
|
679
|
-
pendingRunContext,
|
|
680
|
-
});
|
|
681
|
-
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
682
|
-
this.feed?.publish({
|
|
683
|
-
level: "warn",
|
|
684
|
-
kind: "github",
|
|
685
|
-
issueKey: issue.issueKey,
|
|
686
|
-
projectId: issue.projectId,
|
|
687
|
-
stage: "repairing_queue",
|
|
688
|
-
status: hasEvictionCheckRun ? "queue_health_eviction_detected" : "queue_health_conflict_detected",
|
|
689
|
-
summary: hasEvictionCheckRun
|
|
690
|
-
? `Queue health: missed eviction detected on PR #${issue.prNumber}, dispatching repair`
|
|
691
|
-
: `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
async reconcileIdleIssues() {
|
|
696
|
-
for (const issue of this.db.listIdleNonTerminalIssues()) {
|
|
697
|
-
// PR already merged — advance to done regardless of current state
|
|
698
|
-
if (issue.prState === "merged") {
|
|
699
|
-
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
// Review approved + checks not failed — advance to awaiting_queue
|
|
703
|
-
if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
|
|
704
|
-
if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
|
|
705
|
-
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
706
|
-
}
|
|
707
|
-
else if (!issue.queueLabelApplied) {
|
|
708
|
-
// Retry failed label application
|
|
709
|
-
await this.requestMergeQueueAdmission(issue, issue.projectId);
|
|
710
|
-
}
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
// Checks failed + idle — route based on durable GitHub failure provenance.
|
|
714
|
-
if (issue.prCheckStatus === "failed") {
|
|
715
|
-
if (issue.lastGitHubFailureSource === "queue_eviction") {
|
|
716
|
-
const pendingRunContext = buildFailureContext(issue);
|
|
717
|
-
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
718
|
-
this.advanceIdleIssue(issue, "repairing_queue");
|
|
719
|
-
}
|
|
720
|
-
else {
|
|
721
|
-
this.advanceIdleIssue(issue, "repairing_queue", {
|
|
722
|
-
pendingRunType: "queue_repair",
|
|
723
|
-
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
continue;
|
|
727
|
-
}
|
|
728
|
-
if (issue.lastGitHubFailureSource === "branch_ci") {
|
|
729
|
-
const pendingRunContext = buildFailureContext(issue);
|
|
730
|
-
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
731
|
-
this.advanceIdleIssue(issue, "repairing_ci");
|
|
732
|
-
}
|
|
733
|
-
else {
|
|
734
|
-
this.advanceIdleIssue(issue, "repairing_ci", {
|
|
735
|
-
pendingRunType: "ci_repair",
|
|
736
|
-
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
if (issue.factoryState === "awaiting_queue") {
|
|
742
|
-
// Infer provenance: check if steward eviction check run exists on the PR
|
|
743
|
-
const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
|
|
744
|
-
const inferProtocol = resolveMergeQueueProtocol(inferProject);
|
|
745
|
-
let inferred = "branch_ci";
|
|
746
|
-
const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
|
|
747
|
-
if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
|
|
748
|
-
try {
|
|
749
|
-
const { stdout } = await execCommand("gh", [
|
|
750
|
-
"api",
|
|
751
|
-
`repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
|
|
752
|
-
"--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
|
|
753
|
-
], { timeoutMs: 10_000 });
|
|
754
|
-
if (stdout.trim().length > 0)
|
|
755
|
-
inferred = "queue_eviction";
|
|
756
|
-
}
|
|
757
|
-
catch { /* best effort */ }
|
|
758
|
-
}
|
|
759
|
-
const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
|
|
760
|
-
const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
|
|
761
|
-
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
|
|
762
|
-
const pendingRunContext = buildFailureContext(issue);
|
|
763
|
-
this.advanceIdleIssue(issue, inferState, {
|
|
764
|
-
pendingRunType: inferRunType,
|
|
765
|
-
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
766
|
-
});
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
const pendingRunContext = buildFailureContext(issue);
|
|
770
|
-
if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
|
|
771
|
-
this.advanceIdleIssue(issue, "repairing_ci");
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
this.advanceIdleIssue(issue, "repairing_ci", {
|
|
775
|
-
pendingRunType: "ci_repair",
|
|
776
|
-
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
// For pr_open issues with no review decision, check GitHub for stale metadata
|
|
782
|
-
if (issue.factoryState === "pr_open" && !issue.prReviewState) {
|
|
783
|
-
await this.reconcileFromGitHub(issue);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
// Unblock delegated issues whose blockers have been resolved.
|
|
787
|
-
for (const issue of this.db.listBlockedDelegatedIssues()) {
|
|
788
|
-
const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
789
|
-
if (unresolved === 0) {
|
|
790
|
-
this.db.upsertIssue({
|
|
791
|
-
projectId: issue.projectId,
|
|
792
|
-
linearIssueId: issue.linearIssueId,
|
|
793
|
-
pendingRunType: "implementation",
|
|
794
|
-
});
|
|
795
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
async reconcileFromGitHub(issue) {
|
|
800
|
-
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
801
|
-
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
802
|
-
return;
|
|
803
|
-
try {
|
|
804
|
-
const { stdout } = await execCommand("gh", [
|
|
805
|
-
"pr", "view", String(issue.prNumber),
|
|
806
|
-
"--repo", project.github.repoFullName,
|
|
807
|
-
"--json", "state,reviewDecision",
|
|
808
|
-
], { timeoutMs: 10_000 });
|
|
809
|
-
const pr = JSON.parse(stdout);
|
|
810
|
-
if (pr.state === "MERGED") {
|
|
811
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
812
|
-
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
813
|
-
}
|
|
814
|
-
else if (pr.reviewDecision === "APPROVED") {
|
|
815
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
|
|
816
|
-
this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
catch (error) {
|
|
820
|
-
this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
|
|
821
|
-
}
|
|
545
|
+
await this.idleReconciler.reconcile();
|
|
822
546
|
}
|
|
547
|
+
// advanceIdleIssue is now on IdleIssueReconciler — delegate for internal callers
|
|
823
548
|
advanceIdleIssue(issue, newState, options) {
|
|
824
|
-
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
|
|
828
|
-
// Reset queueLabelApplied when entering or leaving awaiting_queue so
|
|
829
|
-
// the retry loop re-applies the label on each queue cycle.
|
|
830
|
-
const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
|
|
831
|
-
this.db.upsertIssue({
|
|
832
|
-
projectId: issue.projectId,
|
|
833
|
-
linearIssueId: issue.linearIssueId,
|
|
834
|
-
factoryState: newState,
|
|
835
|
-
...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
|
|
836
|
-
...(options?.pendingRunType
|
|
837
|
-
? {
|
|
838
|
-
pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
|
|
839
|
-
}
|
|
840
|
-
: {}),
|
|
841
|
-
...(resetQueueLabel ? { queueLabelApplied: false } : {}),
|
|
842
|
-
...(options?.clearFailureProvenance
|
|
843
|
-
? {
|
|
844
|
-
lastGitHubFailureSource: null,
|
|
845
|
-
lastGitHubFailureHeadSha: null,
|
|
846
|
-
lastGitHubFailureSignature: null,
|
|
847
|
-
lastGitHubFailureCheckName: null,
|
|
848
|
-
lastGitHubFailureCheckUrl: null,
|
|
849
|
-
lastGitHubFailureContextJson: null,
|
|
850
|
-
lastGitHubFailureAt: null,
|
|
851
|
-
lastQueueIncidentJson: null,
|
|
852
|
-
lastAttemptedFailureHeadSha: null,
|
|
853
|
-
lastAttemptedFailureSignature: null,
|
|
854
|
-
}
|
|
855
|
-
: {}),
|
|
856
|
-
});
|
|
857
|
-
const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
|
|
858
|
-
if (branchOwner) {
|
|
859
|
-
this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
|
|
860
|
-
}
|
|
861
|
-
this.feed?.publish({
|
|
862
|
-
level: "info",
|
|
863
|
-
kind: "stage",
|
|
864
|
-
issueKey: issue.issueKey,
|
|
865
|
-
projectId: issue.projectId,
|
|
866
|
-
stage: newState,
|
|
867
|
-
status: "reconciled",
|
|
868
|
-
summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
|
|
869
|
-
});
|
|
870
|
-
if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
|
|
871
|
-
this.requestMergeQueueAdmission(issue, issue.projectId);
|
|
872
|
-
}
|
|
873
|
-
if (options?.pendingRunType) {
|
|
874
|
-
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
875
|
-
}
|
|
549
|
+
this.idleReconciler.advanceIdleIssue(issue, newState, options);
|
|
876
550
|
}
|
|
877
551
|
/**
|
|
878
552
|
* After a zombie/stale run is cleared, decide whether to re-enqueue
|
|
@@ -1036,9 +710,9 @@ export class RunOrchestrator {
|
|
|
1036
710
|
});
|
|
1037
711
|
}
|
|
1038
712
|
else {
|
|
1039
|
-
void this.
|
|
713
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
|
|
1040
714
|
}
|
|
1041
|
-
void this.
|
|
715
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
1042
716
|
return;
|
|
1043
717
|
}
|
|
1044
718
|
// Handle completed turn discovered during reconciliation
|
|
@@ -1135,11 +809,11 @@ export class RunOrchestrator {
|
|
|
1135
809
|
summary: `Escalated: ${reason}`,
|
|
1136
810
|
});
|
|
1137
811
|
const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
1138
|
-
void this.
|
|
812
|
+
void this.linearSync.emitActivity(escalatedIssue, {
|
|
1139
813
|
type: "error",
|
|
1140
814
|
body: `PatchRelay needs human help to continue.\n\n${reason}`,
|
|
1141
815
|
});
|
|
1142
|
-
void this.
|
|
816
|
+
void this.linearSync.syncSession(escalatedIssue);
|
|
1143
817
|
}
|
|
1144
818
|
/** Add the merge queue admission label for external-queue projects (best-effort). */
|
|
1145
819
|
async requestMergeQueueAdmission(issue, projectId) {
|
|
@@ -1171,13 +845,7 @@ export class RunOrchestrator {
|
|
|
1171
845
|
});
|
|
1172
846
|
}
|
|
1173
847
|
resolveBranchOwnerForStateTransition(newState, pendingRunType) {
|
|
1174
|
-
|
|
1175
|
-
return "patchrelay";
|
|
1176
|
-
if (newState === "awaiting_queue")
|
|
1177
|
-
return "merge_steward";
|
|
1178
|
-
if (newState === "repairing_ci" || newState === "repairing_queue")
|
|
1179
|
-
return "patchrelay";
|
|
1180
|
-
return undefined;
|
|
848
|
+
return resolveBranchOwnerForStateTransition(newState, pendingRunType);
|
|
1181
849
|
}
|
|
1182
850
|
async verifyReactiveRunAdvancedBranch(run, issue) {
|
|
1183
851
|
if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
|
|
@@ -1214,93 +882,6 @@ export class RunOrchestrator {
|
|
|
1214
882
|
return undefined;
|
|
1215
883
|
}
|
|
1216
884
|
}
|
|
1217
|
-
async emitLinearActivity(issue, content, options) {
|
|
1218
|
-
if (!issue.agentSessionId)
|
|
1219
|
-
return;
|
|
1220
|
-
try {
|
|
1221
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
1222
|
-
if (!linear)
|
|
1223
|
-
return;
|
|
1224
|
-
const allowEphemeral = content.type === "thought" || content.type === "action";
|
|
1225
|
-
await linear.createAgentActivity({
|
|
1226
|
-
agentSessionId: issue.agentSessionId,
|
|
1227
|
-
content,
|
|
1228
|
-
...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
catch (error) {
|
|
1232
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1233
|
-
this.logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
|
|
1234
|
-
this.feed?.publish({
|
|
1235
|
-
level: "warn",
|
|
1236
|
-
kind: "linear",
|
|
1237
|
-
issueKey: issue.issueKey,
|
|
1238
|
-
projectId: issue.projectId,
|
|
1239
|
-
status: "linear_error",
|
|
1240
|
-
summary: `Linear activity failed: ${msg}`,
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
async syncLinearSession(issue, options) {
|
|
1245
|
-
if (!issue.agentSessionId)
|
|
1246
|
-
return;
|
|
1247
|
-
try {
|
|
1248
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
1249
|
-
if (!linear?.updateAgentSession)
|
|
1250
|
-
return;
|
|
1251
|
-
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
1252
|
-
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
1253
|
-
...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
|
|
1254
|
-
});
|
|
1255
|
-
await linear.updateAgentSession({
|
|
1256
|
-
agentSessionId: issue.agentSessionId,
|
|
1257
|
-
plan: buildAgentSessionPlanForIssue(issue, options),
|
|
1258
|
-
...(externalUrls ? { externalUrls } : {}),
|
|
1259
|
-
});
|
|
1260
|
-
}
|
|
1261
|
-
catch (error) {
|
|
1262
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1263
|
-
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
async syncLinearSessionWithCodexPlan(issue, params) {
|
|
1267
|
-
if (!issue.agentSessionId)
|
|
1268
|
-
return;
|
|
1269
|
-
const plan = params.plan;
|
|
1270
|
-
if (!Array.isArray(plan))
|
|
1271
|
-
return;
|
|
1272
|
-
const STATUS_MAP = {
|
|
1273
|
-
pending: "pending",
|
|
1274
|
-
inProgress: "inProgress",
|
|
1275
|
-
completed: "completed",
|
|
1276
|
-
};
|
|
1277
|
-
const steps = plan.map((entry) => {
|
|
1278
|
-
const e = entry;
|
|
1279
|
-
const step = typeof e.step === "string" ? e.step : String(e.step ?? "");
|
|
1280
|
-
const status = typeof e.status === "string" ? (STATUS_MAP[e.status] ?? "pending") : "pending";
|
|
1281
|
-
return { content: step, status };
|
|
1282
|
-
});
|
|
1283
|
-
// Prepend a "Prepare workspace" completed step and append a "Merge" pending step
|
|
1284
|
-
// to frame the codex plan within the PatchRelay lifecycle
|
|
1285
|
-
const fullPlan = [
|
|
1286
|
-
{ content: "Prepare workspace", status: "completed" },
|
|
1287
|
-
...steps,
|
|
1288
|
-
{ content: "Merge", status: "pending" },
|
|
1289
|
-
];
|
|
1290
|
-
try {
|
|
1291
|
-
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
1292
|
-
if (!linear?.updateAgentSession)
|
|
1293
|
-
return;
|
|
1294
|
-
await linear.updateAgentSession({
|
|
1295
|
-
agentSessionId: issue.agentSessionId,
|
|
1296
|
-
plan: fullPlan,
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
catch (error) {
|
|
1300
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1301
|
-
this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
885
|
async readThreadWithRetry(threadId, maxRetries = 3) {
|
|
1305
886
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1306
887
|
try {
|
|
@@ -1350,40 +931,6 @@ function resolveRecoverablePostRunState(issue) {
|
|
|
1350
931
|
}
|
|
1351
932
|
return resolvePostRunState(issue);
|
|
1352
933
|
}
|
|
1353
|
-
function buildFailureContext(issue) {
|
|
1354
|
-
const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
|
|
1355
|
-
const queueRepairContext = issue.lastQueueIncidentJson
|
|
1356
|
-
? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
|
|
1357
|
-
: undefined;
|
|
1358
|
-
if (!queueRepairContext
|
|
1359
|
-
&& !issue.lastGitHubFailureSource
|
|
1360
|
-
&& !issue.lastGitHubFailureHeadSha
|
|
1361
|
-
&& !issue.lastGitHubFailureSignature
|
|
1362
|
-
&& !issue.lastGitHubFailureCheckName
|
|
1363
|
-
&& !issue.lastGitHubFailureCheckUrl
|
|
1364
|
-
&& !storedFailureContext) {
|
|
1365
|
-
return undefined;
|
|
1366
|
-
}
|
|
1367
|
-
return {
|
|
1368
|
-
...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
|
|
1369
|
-
...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
|
|
1370
|
-
...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
|
|
1371
|
-
...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
|
|
1372
|
-
...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
|
|
1373
|
-
...(storedFailureContext ? storedFailureContext : {}),
|
|
1374
|
-
...(queueRepairContext ? queueRepairContext : {}),
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1377
|
-
function isDuplicateRepairAttempt(issue, context) {
|
|
1378
|
-
const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
|
|
1379
|
-
const headSha = typeof context?.failureHeadSha === "string"
|
|
1380
|
-
? context.failureHeadSha
|
|
1381
|
-
: typeof context?.headSha === "string" ? context.headSha : undefined;
|
|
1382
|
-
if (!signature)
|
|
1383
|
-
return false;
|
|
1384
|
-
return issue.lastAttemptedFailureSignature === signature
|
|
1385
|
-
&& (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
|
|
1386
|
-
}
|
|
1387
934
|
function appendQueueRepairContext(lines, context) {
|
|
1388
935
|
const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
|
|
1389
936
|
const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
|