patchrelay 0.36.11 → 0.36.13
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.js +10 -45
- package/dist/implementation-outcome-policy.js +127 -0
- package/dist/issue-overview-query.js +232 -0
- package/dist/issue-query-service.js +6 -218
- package/dist/linear-session-reporting.js +49 -35
- package/dist/reactive-run-policy.js +288 -0
- package/dist/run-completion-policy.js +12 -378
- package/dist/run-finalizer.js +5 -2
- package/dist/run-notification-handler.js +123 -0
- package/dist/run-orchestrator.js +12 -210
- package/dist/run-reconciler.js +132 -0
- package/dist/tracked-issue-query.js +67 -0
- package/dist/webhook-handler.js +12 -93
- package/dist/webhooks/agent-session-handler.js +6 -7
- package/dist/webhooks/context-loader.js +70 -0
- package/dist/webhooks/dependency-readiness-handler.js +52 -0
- package/package.json +1 -1
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
2
|
-
import { execCommand } from "./utils.js";
|
|
3
2
|
import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
import { ImplementationOutcomePolicy } from "./implementation-outcome-policy.js";
|
|
4
|
+
import { ReactiveRunPolicy } from "./reactive-run-policy.js";
|
|
7
5
|
function resolvePostRunState(issue) {
|
|
8
6
|
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
|
|
9
7
|
if (issue.prState === "merged")
|
|
@@ -20,393 +18,29 @@ export function resolveCompletedRunState(issue, run) {
|
|
|
20
18
|
}
|
|
21
19
|
return resolvePostRunState(issue);
|
|
22
20
|
}
|
|
23
|
-
function normalizeRemotePrState(value) {
|
|
24
|
-
const normalized = value?.trim().toUpperCase();
|
|
25
|
-
if (normalized === "OPEN")
|
|
26
|
-
return "open";
|
|
27
|
-
if (normalized === "CLOSED")
|
|
28
|
-
return "closed";
|
|
29
|
-
if (normalized === "MERGED")
|
|
30
|
-
return "merged";
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
function normalizeRemoteReviewDecision(value) {
|
|
34
|
-
const normalized = value?.trim().toUpperCase();
|
|
35
|
-
if (normalized === "APPROVED")
|
|
36
|
-
return "approved";
|
|
37
|
-
if (normalized === "CHANGES_REQUESTED")
|
|
38
|
-
return "changes_requested";
|
|
39
|
-
if (normalized === "REVIEW_REQUIRED")
|
|
40
|
-
return "commented";
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
function isDirtyMergeStateStatus(value) {
|
|
44
|
-
return value?.trim().toUpperCase() === "DIRTY";
|
|
45
|
-
}
|
|
46
|
-
function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
|
|
47
|
-
const promptContext = [
|
|
48
|
-
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
|
|
49
|
-
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
50
|
-
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
51
|
-
].join(" ");
|
|
52
|
-
return {
|
|
53
|
-
...(context ?? {}),
|
|
54
|
-
branchUpkeepRequired: true,
|
|
55
|
-
reviewFixMode: "branch_upkeep",
|
|
56
|
-
wakeReason: "branch_upkeep",
|
|
57
|
-
promptContext,
|
|
58
|
-
...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
|
|
59
|
-
...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
|
|
60
|
-
baseBranch,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
21
|
export class RunCompletionPolicy {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
logger;
|
|
67
|
-
withHeldLease;
|
|
22
|
+
reactive;
|
|
23
|
+
implementationOutcomes;
|
|
68
24
|
constructor(config, db, logger, withHeldLease) {
|
|
69
|
-
this.
|
|
70
|
-
this.
|
|
71
|
-
this.logger = logger;
|
|
72
|
-
this.withHeldLease = withHeldLease;
|
|
25
|
+
this.reactive = new ReactiveRunPolicy(config, db, logger, withHeldLease);
|
|
26
|
+
this.implementationOutcomes = new ImplementationOutcomePolicy(config, db, logger, withHeldLease);
|
|
73
27
|
}
|
|
74
28
|
async verifyReactiveRunAdvancedBranch(run, issue) {
|
|
75
|
-
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
82
|
-
if (!project?.github?.repoFullName) {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
try {
|
|
86
|
-
const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
|
|
87
|
-
if (!pr || pr.state?.toUpperCase() !== "OPEN")
|
|
88
|
-
return undefined;
|
|
89
|
-
if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
|
|
90
|
-
return undefined;
|
|
91
|
-
return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
|
|
92
|
-
}
|
|
93
|
-
catch (error) {
|
|
94
|
-
this.logger.debug({
|
|
95
|
-
issueKey: issue.issueKey,
|
|
96
|
-
prNumber: issue.prNumber,
|
|
97
|
-
error: error instanceof Error ? error.message : String(error),
|
|
98
|
-
}, "Failed to verify PR head advancement after repair");
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
29
|
+
return await this.reactive.verifyReactiveRunAdvancedBranch(run, issue);
|
|
101
30
|
}
|
|
102
31
|
async verifyReviewFixAdvancedHead(run, issue) {
|
|
103
|
-
|
|
104
|
-
return undefined;
|
|
105
|
-
}
|
|
106
|
-
if (!issue.prNumber || issue.prState !== "open") {
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
if (!run.sourceHeadSha) {
|
|
110
|
-
return `Requested-changes run finished for PR #${issue.prNumber} without a recorded starting head SHA. PatchRelay cannot verify that a new head was published.`;
|
|
111
|
-
}
|
|
112
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
113
|
-
if (!project?.github?.repoFullName) {
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
|
|
118
|
-
if (!pr || pr.state?.toUpperCase() !== "OPEN")
|
|
119
|
-
return undefined;
|
|
120
|
-
if (!pr.headRefOid) {
|
|
121
|
-
return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
|
|
122
|
-
}
|
|
123
|
-
if (pr.headRefOid === run.sourceHeadSha) {
|
|
124
|
-
return `Requested-changes run finished for PR #${issue.prNumber} without pushing a new head; PatchRelay must not hand the same SHA back to review.`;
|
|
125
|
-
}
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
this.logger.debug({
|
|
130
|
-
issueKey: issue.issueKey,
|
|
131
|
-
prNumber: issue.prNumber,
|
|
132
|
-
error: error instanceof Error ? error.message : String(error),
|
|
133
|
-
}, "Failed to verify PR head advancement after requested-changes work");
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
32
|
+
return await this.reactive.verifyReviewFixAdvancedHead(run, issue);
|
|
136
33
|
}
|
|
137
34
|
async refreshIssueAfterReactivePublish(run, issue) {
|
|
138
|
-
|
|
139
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
140
|
-
}
|
|
141
|
-
if (!issue.prNumber) {
|
|
142
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
143
|
-
}
|
|
144
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
145
|
-
const repoFullName = project?.github?.repoFullName;
|
|
146
|
-
if (!repoFullName) {
|
|
147
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
148
|
-
}
|
|
149
|
-
try {
|
|
150
|
-
const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
|
|
151
|
-
if (!pr) {
|
|
152
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
153
|
-
}
|
|
154
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
155
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
156
|
-
const gateCheckName = project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify";
|
|
157
|
-
const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== issue.lastGitHubFailureHeadSha);
|
|
158
|
-
const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
|
|
159
|
-
&& Boolean(pr.headRefOid && run.sourceHeadSha && pr.headRefOid !== run.sourceHeadSha);
|
|
160
|
-
this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
|
|
161
|
-
projectId: run.projectId,
|
|
162
|
-
linearIssueId: run.linearIssueId,
|
|
163
|
-
...(nextPrState ? { prState: nextPrState } : {}),
|
|
164
|
-
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
165
|
-
...(nextReviewState ? { prReviewState: nextReviewState } : {}),
|
|
166
|
-
...((headAdvanced || reviewFixHeadAdvanced)
|
|
167
|
-
? {
|
|
168
|
-
prCheckStatus: "pending",
|
|
169
|
-
lastGitHubFailureSource: null,
|
|
170
|
-
lastGitHubFailureHeadSha: null,
|
|
171
|
-
lastGitHubFailureSignature: null,
|
|
172
|
-
lastGitHubFailureCheckName: null,
|
|
173
|
-
lastGitHubFailureCheckUrl: null,
|
|
174
|
-
lastGitHubFailureContextJson: null,
|
|
175
|
-
lastGitHubFailureAt: null,
|
|
176
|
-
lastQueueIncidentJson: null,
|
|
177
|
-
lastAttemptedFailureHeadSha: null,
|
|
178
|
-
lastAttemptedFailureSignature: null,
|
|
179
|
-
lastGitHubCiSnapshotHeadSha: pr.headRefOid ?? null,
|
|
180
|
-
lastGitHubCiSnapshotGateCheckName: gateCheckName,
|
|
181
|
-
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
182
|
-
lastGitHubCiSnapshotJson: null,
|
|
183
|
-
lastGitHubCiSnapshotSettledAt: null,
|
|
184
|
-
}
|
|
185
|
-
: {}),
|
|
186
|
-
}, "reactive publish refresh");
|
|
187
|
-
}
|
|
188
|
-
catch (error) {
|
|
189
|
-
this.logger.debug({
|
|
190
|
-
issueKey: issue.issueKey,
|
|
191
|
-
prNumber: issue.prNumber,
|
|
192
|
-
error: error instanceof Error ? error.message : String(error),
|
|
193
|
-
}, "Failed to refresh PR state after reactive publish");
|
|
194
|
-
}
|
|
195
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
35
|
+
return await this.reactive.refreshIssueAfterReactivePublish(run, issue);
|
|
196
36
|
}
|
|
197
37
|
async resolveRequestedChangesWakeContext(issue, runType, context) {
|
|
198
|
-
|
|
199
|
-
return context;
|
|
200
|
-
}
|
|
201
|
-
if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
|
|
202
|
-
return context;
|
|
203
|
-
}
|
|
204
|
-
const project = this.config.projects.find((entry) => entry.id === issue.projectId);
|
|
205
|
-
const repoFullName = project?.github?.repoFullName;
|
|
206
|
-
if (!repoFullName) {
|
|
207
|
-
return context;
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
|
|
211
|
-
if (!pr)
|
|
212
|
-
return context;
|
|
213
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
214
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
215
|
-
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
216
|
-
projectId: issue.projectId,
|
|
217
|
-
linearIssueId: issue.linearIssueId,
|
|
218
|
-
...(nextPrState ? { prState: nextPrState } : {}),
|
|
219
|
-
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
220
|
-
...(nextReviewState ? { prReviewState: nextReviewState } : {}),
|
|
221
|
-
}, "review-fix wake refresh");
|
|
222
|
-
if (nextPrState !== "open")
|
|
223
|
-
return context;
|
|
224
|
-
if (nextReviewState && nextReviewState !== "changes_requested")
|
|
225
|
-
return context;
|
|
226
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
227
|
-
return context;
|
|
228
|
-
return buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr, context);
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
this.logger.debug({
|
|
232
|
-
issueKey: issue.issueKey,
|
|
233
|
-
prNumber: issue.prNumber,
|
|
234
|
-
error: error instanceof Error ? error.message : String(error),
|
|
235
|
-
}, "Failed to resolve requested-changes wake context");
|
|
236
|
-
return context;
|
|
237
|
-
}
|
|
38
|
+
return await this.reactive.resolveRequestedChangesWakeContext(issue, runType, context);
|
|
238
39
|
}
|
|
239
40
|
async resolvePostRunFollowUp(run, issue) {
|
|
240
|
-
|
|
241
|
-
return undefined;
|
|
242
|
-
}
|
|
243
|
-
if (!issue.prNumber || issue.prState !== "open") {
|
|
244
|
-
return undefined;
|
|
245
|
-
}
|
|
246
|
-
if (issue.prReviewState !== "changes_requested") {
|
|
247
|
-
return undefined;
|
|
248
|
-
}
|
|
249
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
250
|
-
const repoFullName = project?.github?.repoFullName;
|
|
251
|
-
if (!repoFullName) {
|
|
252
|
-
return undefined;
|
|
253
|
-
}
|
|
254
|
-
try {
|
|
255
|
-
const pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
|
|
256
|
-
if (!pr)
|
|
257
|
-
return undefined;
|
|
258
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
259
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
260
|
-
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
261
|
-
projectId: issue.projectId,
|
|
262
|
-
linearIssueId: issue.linearIssueId,
|
|
263
|
-
...(nextPrState ? { prState: nextPrState } : {}),
|
|
264
|
-
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
265
|
-
...(nextReviewState ? { prReviewState: nextReviewState } : {}),
|
|
266
|
-
}, "post-run follow-up refresh");
|
|
267
|
-
if (nextPrState !== "open")
|
|
268
|
-
return undefined;
|
|
269
|
-
if (nextReviewState && nextReviewState !== "changes_requested")
|
|
270
|
-
return undefined;
|
|
271
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
272
|
-
return undefined;
|
|
273
|
-
return {
|
|
274
|
-
pendingRunType: "branch_upkeep",
|
|
275
|
-
factoryState: "changes_requested",
|
|
276
|
-
context: buildReviewFixBranchUpkeepContext(issue.prNumber, project?.github?.baseBranch ?? "main", pr),
|
|
277
|
-
summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
catch (error) {
|
|
281
|
-
this.logger.debug({
|
|
282
|
-
issueKey: issue.issueKey,
|
|
283
|
-
prNumber: issue.prNumber,
|
|
284
|
-
error: error instanceof Error ? error.message : String(error),
|
|
285
|
-
}, "Failed to resolve post-run PR upkeep");
|
|
286
|
-
return undefined;
|
|
287
|
-
}
|
|
41
|
+
return await this.reactive.resolvePostRunFollowUp(run, issue);
|
|
288
42
|
}
|
|
289
43
|
async verifyPublishedRunOutcome(run, issue) {
|
|
290
|
-
|
|
291
|
-
return undefined;
|
|
292
|
-
}
|
|
293
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
294
|
-
const baseBranch = project?.github?.baseBranch ?? "main";
|
|
295
|
-
const deliveryMode = resolveImplementationDeliveryMode(issue, undefined, run.promptText);
|
|
296
|
-
if (deliveryMode === "linear_only") {
|
|
297
|
-
if (issue.prNumber !== undefined) {
|
|
298
|
-
return `Planning-only implementation should not open a PR, but PR #${issue.prNumber} was observed`;
|
|
299
|
-
}
|
|
300
|
-
return this.describeLocalImplementationOutcome(issue, baseBranch, deliveryMode);
|
|
301
|
-
}
|
|
302
|
-
if (issue.prNumber && issue.prState && issue.prState !== "closed") {
|
|
303
|
-
return undefined;
|
|
304
|
-
}
|
|
305
|
-
if (project?.github?.repoFullName && issue.branchName) {
|
|
306
|
-
try {
|
|
307
|
-
const { stdout, exitCode } = await execCommand("gh", [
|
|
308
|
-
"pr",
|
|
309
|
-
"list",
|
|
310
|
-
"--repo",
|
|
311
|
-
project.github.repoFullName,
|
|
312
|
-
"--head",
|
|
313
|
-
issue.branchName,
|
|
314
|
-
"--state",
|
|
315
|
-
"all",
|
|
316
|
-
"--json",
|
|
317
|
-
"number,url,state,author,headRefOid",
|
|
318
|
-
], { timeoutMs: 10_000 });
|
|
319
|
-
if (exitCode === 0) {
|
|
320
|
-
const matches = JSON.parse(stdout);
|
|
321
|
-
const pr = matches[0];
|
|
322
|
-
if (pr?.number) {
|
|
323
|
-
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
324
|
-
projectId: issue.projectId,
|
|
325
|
-
linearIssueId: issue.linearIssueId,
|
|
326
|
-
prNumber: pr.number,
|
|
327
|
-
...(pr.url ? { prUrl: pr.url } : {}),
|
|
328
|
-
...(pr.state ? { prState: pr.state.toLowerCase() } : {}),
|
|
329
|
-
...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
|
|
330
|
-
...(pr.author?.login ? { prAuthorLogin: pr.author.login } : {}),
|
|
331
|
-
}, "published PR verification refresh");
|
|
332
|
-
return undefined;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
catch (error) {
|
|
337
|
-
this.logger.debug({
|
|
338
|
-
issueKey: issue.issueKey,
|
|
339
|
-
branchName: issue.branchName,
|
|
340
|
-
repoFullName: project.github.repoFullName,
|
|
341
|
-
error: error instanceof Error ? error.message : String(error),
|
|
342
|
-
}, "Failed to verify published PR state after implementation");
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
const details = await this.describeLocalImplementationOutcome(issue, baseBranch, deliveryMode);
|
|
346
|
-
return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
|
|
347
|
-
}
|
|
348
|
-
async loadRemotePrState(repoFullName, prNumber) {
|
|
349
|
-
const { stdout, exitCode } = await execCommand("gh", [
|
|
350
|
-
"pr", "view", String(prNumber),
|
|
351
|
-
"--repo", repoFullName,
|
|
352
|
-
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
353
|
-
], { timeoutMs: 10_000 });
|
|
354
|
-
if (exitCode !== 0)
|
|
355
|
-
return undefined;
|
|
356
|
-
return JSON.parse(stdout);
|
|
357
|
-
}
|
|
358
|
-
upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
|
|
359
|
-
const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
|
|
360
|
-
if (updated === undefined) {
|
|
361
|
-
this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
|
|
362
|
-
}
|
|
363
|
-
return updated;
|
|
364
|
-
}
|
|
365
|
-
async describeLocalImplementationOutcome(issue, baseBranch, deliveryMode = "publish_pr") {
|
|
366
|
-
if (!issue.worktreePath) {
|
|
367
|
-
return undefined;
|
|
368
|
-
}
|
|
369
|
-
try {
|
|
370
|
-
const status = await execCommand(this.config.runner.gitBin, [
|
|
371
|
-
"-C",
|
|
372
|
-
issue.worktreePath,
|
|
373
|
-
"status",
|
|
374
|
-
"--short",
|
|
375
|
-
], { timeoutMs: 10_000 });
|
|
376
|
-
const dirtyEntries = status.exitCode === 0
|
|
377
|
-
? status.stdout.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
378
|
-
: [];
|
|
379
|
-
if (dirtyEntries.length > 0) {
|
|
380
|
-
if (deliveryMode === "linear_only") {
|
|
381
|
-
return `Planning-only implementation should not modify the repo; worktree still has ${dirtyEntries.length} uncommitted change(s)`;
|
|
382
|
-
}
|
|
383
|
-
return `Implementation completed without opening a PR; worktree still has ${dirtyEntries.length} uncommitted change(s)`;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// Best effort only.
|
|
388
|
-
}
|
|
389
|
-
try {
|
|
390
|
-
const ahead = await execCommand(this.config.runner.gitBin, [
|
|
391
|
-
"-C",
|
|
392
|
-
issue.worktreePath,
|
|
393
|
-
"rev-list",
|
|
394
|
-
"--count",
|
|
395
|
-
`origin/${baseBranch}..HEAD`,
|
|
396
|
-
], { timeoutMs: 10_000 });
|
|
397
|
-
if (ahead.exitCode === 0) {
|
|
398
|
-
const count = Number(ahead.stdout.trim());
|
|
399
|
-
if (Number.isFinite(count) && count > 0) {
|
|
400
|
-
if (deliveryMode === "linear_only") {
|
|
401
|
-
return `Planning-only implementation should not create repo commits; worktree is ${count} local commit(s) ahead of origin/${baseBranch}`;
|
|
402
|
-
}
|
|
403
|
-
return `Implementation completed with ${count} local commit(s) ahead of origin/${baseBranch} but no PR was observed`;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
// Best effort only.
|
|
409
|
-
}
|
|
410
|
-
return undefined;
|
|
44
|
+
return await this.implementationOutcomes.verifyPublishedRunOutcome(run, issue);
|
|
411
45
|
}
|
|
412
46
|
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -159,12 +159,15 @@ export class RunFinalizer {
|
|
|
159
159
|
});
|
|
160
160
|
const updatedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
161
161
|
const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
|
|
162
|
-
|
|
162
|
+
const linearActivity = buildRunCompletedActivity({
|
|
163
163
|
runType: run.runType,
|
|
164
164
|
completionSummary,
|
|
165
165
|
postRunState: updatedIssue.factoryState,
|
|
166
166
|
...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
|
|
167
|
-
})
|
|
167
|
+
});
|
|
168
|
+
if (linearActivity) {
|
|
169
|
+
void this.linearSync.emitActivity(updatedIssue, linearActivity);
|
|
170
|
+
}
|
|
168
171
|
void this.linearSync.syncSession(updatedIssue);
|
|
169
172
|
this.linearSync.clearProgress(run.id);
|
|
170
173
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
2
|
+
import { extractTurnId, resolveRunCompletionStatus } from "./run-reporting.js";
|
|
3
|
+
import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
|
|
4
|
+
function isRequestedChangesRunType(runType) {
|
|
5
|
+
return runType === "review_fix" || runType === "branch_upkeep";
|
|
6
|
+
}
|
|
7
|
+
export class RunNotificationHandler {
|
|
8
|
+
config;
|
|
9
|
+
db;
|
|
10
|
+
logger;
|
|
11
|
+
linearSync;
|
|
12
|
+
runFinalizer;
|
|
13
|
+
readThreadWithRetry;
|
|
14
|
+
withHeldIssueSessionLease;
|
|
15
|
+
heartbeatIssueSessionLease;
|
|
16
|
+
releaseIssueSessionLease;
|
|
17
|
+
feed;
|
|
18
|
+
activeThreadId;
|
|
19
|
+
constructor(config, db, logger, linearSync, runFinalizer, readThreadWithRetry, withHeldIssueSessionLease, heartbeatIssueSessionLease, releaseIssueSessionLease, feed) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.db = db;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
this.linearSync = linearSync;
|
|
24
|
+
this.runFinalizer = runFinalizer;
|
|
25
|
+
this.readThreadWithRetry = readThreadWithRetry;
|
|
26
|
+
this.withHeldIssueSessionLease = withHeldIssueSessionLease;
|
|
27
|
+
this.heartbeatIssueSessionLease = heartbeatIssueSessionLease;
|
|
28
|
+
this.releaseIssueSessionLease = releaseIssueSessionLease;
|
|
29
|
+
this.feed = feed;
|
|
30
|
+
}
|
|
31
|
+
async handle(notification) {
|
|
32
|
+
let threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
|
|
33
|
+
if (!threadId) {
|
|
34
|
+
threadId = this.activeThreadId;
|
|
35
|
+
}
|
|
36
|
+
if (!threadId)
|
|
37
|
+
return;
|
|
38
|
+
if (notification.method === "turn/started") {
|
|
39
|
+
this.activeThreadId = threadId;
|
|
40
|
+
}
|
|
41
|
+
const run = this.db.runs.getRunByThreadId(threadId);
|
|
42
|
+
if (!run)
|
|
43
|
+
return;
|
|
44
|
+
if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
|
|
45
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Ignoring Codex notification after losing issue-session lease");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
|
|
49
|
+
if (this.config.runner.codex.persistExtendedHistory) {
|
|
50
|
+
this.db.runs.saveThreadEvent({
|
|
51
|
+
runId: run.id,
|
|
52
|
+
threadId,
|
|
53
|
+
...(turnId ? { turnId } : {}),
|
|
54
|
+
method: notification.method,
|
|
55
|
+
eventJson: JSON.stringify(notification.params),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
this.linearSync.maybeEmitProgress(notification, run);
|
|
59
|
+
if (notification.method === "turn/plan/updated") {
|
|
60
|
+
const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
61
|
+
if (issue) {
|
|
62
|
+
void this.linearSync.syncCodexPlan(issue, notification.params);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (notification.method !== "turn/completed")
|
|
66
|
+
return;
|
|
67
|
+
const thread = await this.readThreadWithRetry(threadId);
|
|
68
|
+
const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
69
|
+
if (!issue)
|
|
70
|
+
return;
|
|
71
|
+
const completedTurnId = extractTurnId(notification.params);
|
|
72
|
+
const status = resolveRunCompletionStatus(notification.params);
|
|
73
|
+
if (status === "failed") {
|
|
74
|
+
const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
|
|
75
|
+
const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
|
|
76
|
+
this.db.issueSessions.finishRunWithLease(lease, run.id, {
|
|
77
|
+
status: "failed",
|
|
78
|
+
threadId,
|
|
79
|
+
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
80
|
+
failureReason: "Codex reported the turn completed in a failed state",
|
|
81
|
+
});
|
|
82
|
+
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
83
|
+
projectId: run.projectId,
|
|
84
|
+
linearIssueId: run.linearIssueId,
|
|
85
|
+
activeRunId: null,
|
|
86
|
+
factoryState: nextState,
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
if (!updated) {
|
|
91
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failed-turn cleanup after losing issue-session lease");
|
|
92
|
+
this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.feed?.publish({
|
|
96
|
+
level: "error",
|
|
97
|
+
kind: "turn",
|
|
98
|
+
issueKey: issue.issueKey,
|
|
99
|
+
projectId: run.projectId,
|
|
100
|
+
stage: run.runType,
|
|
101
|
+
status: "failed",
|
|
102
|
+
summary: `Turn failed for ${run.runType}`,
|
|
103
|
+
});
|
|
104
|
+
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
105
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType));
|
|
106
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
107
|
+
this.linearSync.clearProgress(run.id);
|
|
108
|
+
this.activeThreadId = undefined;
|
|
109
|
+
this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await this.runFinalizer.finalizeCompletedRun({
|
|
113
|
+
source: "notification",
|
|
114
|
+
run,
|
|
115
|
+
issue,
|
|
116
|
+
thread,
|
|
117
|
+
threadId,
|
|
118
|
+
...(completedTurnId ? { completedTurnId } : {}),
|
|
119
|
+
resolveRecoverableRunState: resolveRecoverablePostRunState,
|
|
120
|
+
});
|
|
121
|
+
this.activeThreadId = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|