patchrelay 0.36.8 → 0.36.10
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/cli/cluster-health.js +1 -1
- package/dist/cli/data.js +12 -10
- package/dist/cli/formatters/text.js +3 -1
- package/dist/db/issue-session-store.js +15 -23
- package/dist/db/issue-store.js +559 -0
- package/dist/db/run-store.js +10 -12
- package/dist/db.js +37 -625
- package/dist/github-webhook-handler.js +36 -20
- package/dist/idle-reconciliation.js +26 -15
- package/dist/interrupted-run-recovery.js +176 -0
- package/dist/issue-query-service.js +4 -4
- package/dist/issue-session-projector.js +114 -0
- package/dist/linear-session-sync.js +6 -6
- package/dist/queue-health-monitor.js +3 -3
- package/dist/run-completion-policy.js +412 -0
- package/dist/run-finalizer.js +34 -23
- package/dist/run-launcher.js +5 -5
- package/dist/run-orchestrator.js +46 -684
- package/dist/run-recovery-service.js +26 -18
- package/dist/run-wake-planner.js +1 -1
- package/dist/service.js +9 -9
- package/dist/webhook-handler.js +5 -5
- package/dist/webhooks/agent-session-handler.js +7 -7
- package/dist/webhooks/comment-wake-handler.js +1 -1
- package/dist/webhooks/desired-stage-recorder.js +5 -5
- package/dist/webhooks/issue-removal-handler.js +3 -3
- package/dist/worktree-manager.js +69 -0
- package/dist/zombie-recovery.js +13 -0
- package/package.json +1 -1
|
@@ -26,7 +26,7 @@ export class LinearSessionSync {
|
|
|
26
26
|
if (!recoveredAgentSessionId)
|
|
27
27
|
return issue;
|
|
28
28
|
this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
|
|
29
|
-
return this.db.upsertIssue({
|
|
29
|
+
return this.db.issues.upsertIssue({
|
|
30
30
|
projectId: issue.projectId,
|
|
31
31
|
linearIssueId: issue.linearIssueId,
|
|
32
32
|
agentSessionId: recoveredAgentSessionId,
|
|
@@ -99,7 +99,7 @@ export class LinearSessionSync {
|
|
|
99
99
|
currentLinearState: liveIssue.stateName,
|
|
100
100
|
currentLinearStateType: liveIssue.stateType,
|
|
101
101
|
})) {
|
|
102
|
-
this.db.upsertIssue({
|
|
102
|
+
this.db.issues.upsertIssue({
|
|
103
103
|
projectId: issue.projectId,
|
|
104
104
|
linearIssueId: issue.linearIssueId,
|
|
105
105
|
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
@@ -112,7 +112,7 @@ export class LinearSessionSync {
|
|
|
112
112
|
return;
|
|
113
113
|
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
114
114
|
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
115
|
-
this.db.upsertIssue({
|
|
115
|
+
this.db.issues.upsertIssue({
|
|
116
116
|
projectId: issue.projectId,
|
|
117
117
|
linearIssueId: issue.linearIssueId,
|
|
118
118
|
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
@@ -121,7 +121,7 @@ export class LinearSessionSync {
|
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
124
|
-
this.db.upsertIssue({
|
|
124
|
+
this.db.issues.upsertIssue({
|
|
125
125
|
projectId: issue.projectId,
|
|
126
126
|
linearIssueId: issue.linearIssueId,
|
|
127
127
|
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
@@ -174,7 +174,7 @@ export class LinearSessionSync {
|
|
|
174
174
|
if (now - lastEmit < PROGRESS_THROTTLE_MS)
|
|
175
175
|
return;
|
|
176
176
|
this.progressThrottle.set(run.id, now);
|
|
177
|
-
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
177
|
+
const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
178
178
|
if (issue) {
|
|
179
179
|
void this.emitActivity(issue, activity, { ephemeral: true });
|
|
180
180
|
}
|
|
@@ -192,7 +192,7 @@ export class LinearSessionSync {
|
|
|
192
192
|
body,
|
|
193
193
|
});
|
|
194
194
|
if (result.id !== issue.statusCommentId) {
|
|
195
|
-
this.db.upsertIssue({
|
|
195
|
+
this.db.issues.upsertIssue({
|
|
196
196
|
projectId: issue.projectId,
|
|
197
197
|
linearIssueId: issue.linearIssueId,
|
|
198
198
|
statusCommentId: result.id,
|
|
@@ -25,7 +25,7 @@ export class QueueHealthMonitor {
|
|
|
25
25
|
this.feed = feed;
|
|
26
26
|
}
|
|
27
27
|
async reconcile() {
|
|
28
|
-
for (const issue of this.db.listAwaitingQueueIssues()) {
|
|
28
|
+
for (const issue of this.db.issues.listAwaitingQueueIssues()) {
|
|
29
29
|
await this.probeQueuedIssue(issue);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -68,7 +68,7 @@ export class QueueHealthMonitor {
|
|
|
68
68
|
}
|
|
69
69
|
this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
|
|
70
70
|
if (pr.state === "MERGED") {
|
|
71
|
-
this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
71
|
+
this.db.issues.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
|
|
72
72
|
this.advancer.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
@@ -101,7 +101,7 @@ export class QueueHealthMonitor {
|
|
|
101
101
|
if (isDuplicateProbe(issue, pendingRunContext)) {
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
|
-
this.db.upsertIssue({
|
|
104
|
+
this.db.issues.upsertIssue({
|
|
105
105
|
projectId: issue.projectId,
|
|
106
106
|
linearIssueId: issue.linearIssueId,
|
|
107
107
|
lastAttemptedFailureHeadSha: headRefOid,
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
2
|
+
import { execCommand } from "./utils.js";
|
|
3
|
+
import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
|
|
4
|
+
function isRequestedChangesRunType(runType) {
|
|
5
|
+
return runType === "review_fix" || runType === "branch_upkeep";
|
|
6
|
+
}
|
|
7
|
+
function resolvePostRunState(issue) {
|
|
8
|
+
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
|
|
9
|
+
if (issue.prState === "merged")
|
|
10
|
+
return "done";
|
|
11
|
+
if (issue.prReviewState === "approved")
|
|
12
|
+
return "awaiting_queue";
|
|
13
|
+
return "pr_open";
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function resolveCompletedRunState(issue, run) {
|
|
18
|
+
if (run.runType === "implementation" && resolveImplementationDeliveryMode(issue, undefined, run.promptText) === "linear_only") {
|
|
19
|
+
return "done";
|
|
20
|
+
}
|
|
21
|
+
return resolvePostRunState(issue);
|
|
22
|
+
}
|
|
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
|
+
export class RunCompletionPolicy {
|
|
64
|
+
config;
|
|
65
|
+
db;
|
|
66
|
+
logger;
|
|
67
|
+
withHeldLease;
|
|
68
|
+
constructor(config, db, logger, withHeldLease) {
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.db = db;
|
|
71
|
+
this.logger = logger;
|
|
72
|
+
this.withHeldLease = withHeldLease;
|
|
73
|
+
}
|
|
74
|
+
async verifyReactiveRunAdvancedBranch(run, issue) {
|
|
75
|
+
if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
|
|
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
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async verifyReviewFixAdvancedHead(run, issue) {
|
|
103
|
+
if (!isRequestedChangesRunType(run.runType)) {
|
|
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
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async refreshIssueAfterReactivePublish(run, issue) {
|
|
138
|
+
if (run.runType !== "ci_repair" && run.runType !== "queue_repair" && !isRequestedChangesRunType(run.runType)) {
|
|
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;
|
|
196
|
+
}
|
|
197
|
+
async resolveRequestedChangesWakeContext(issue, runType, context) {
|
|
198
|
+
if (runType === "branch_upkeep" || context?.branchUpkeepRequired === true) {
|
|
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
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async resolvePostRunFollowUp(run, issue) {
|
|
240
|
+
if (run.runType !== "review_fix") {
|
|
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
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async verifyPublishedRunOutcome(run, issue) {
|
|
290
|
+
if (run.runType !== "implementation") {
|
|
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;
|
|
411
|
+
}
|
|
412
|
+
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
import { buildStageReport, countEventMethods } from "./run-reporting.js";
|
|
2
2
|
import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
|
+
import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
3
4
|
export class RunFinalizer {
|
|
4
5
|
db;
|
|
5
6
|
logger;
|
|
6
7
|
linearSync;
|
|
7
8
|
enqueueIssue;
|
|
9
|
+
withHeldLease;
|
|
10
|
+
releaseLease;
|
|
11
|
+
appendWakeEventWithLease;
|
|
12
|
+
failRunAndClear;
|
|
13
|
+
completionPolicy;
|
|
8
14
|
feed;
|
|
9
|
-
constructor(db, logger, linearSync, enqueueIssue, feed) {
|
|
15
|
+
constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, feed) {
|
|
10
16
|
this.db = db;
|
|
11
17
|
this.logger = logger;
|
|
12
18
|
this.linearSync = linearSync;
|
|
13
19
|
this.enqueueIssue = enqueueIssue;
|
|
20
|
+
this.withHeldLease = withHeldLease;
|
|
21
|
+
this.releaseLease = releaseLease;
|
|
22
|
+
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
23
|
+
this.failRunAndClear = failRunAndClear;
|
|
24
|
+
this.completionPolicy = completionPolicy;
|
|
14
25
|
this.feed = feed;
|
|
15
26
|
}
|
|
16
27
|
async finalizeCompletedRun(params) {
|
|
17
28
|
const { run, issue, thread, threadId } = params;
|
|
18
29
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
19
30
|
const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
|
|
20
|
-
const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
21
|
-
const verifiedRepairError = await
|
|
31
|
+
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
32
|
+
const verifiedRepairError = await this.completionPolicy.verifyReactiveRunAdvancedBranch(run, freshIssue);
|
|
22
33
|
if (verifiedRepairError) {
|
|
23
34
|
const holdState = params.resolveRecoverableRunState(freshIssue) ?? "failed";
|
|
24
|
-
|
|
25
|
-
const heldIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
35
|
+
this.failRunAndClear(run, verifiedRepairError, holdState);
|
|
36
|
+
const heldIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
26
37
|
this.feed?.publish({
|
|
27
38
|
level: "warn",
|
|
28
39
|
kind: "turn",
|
|
@@ -35,13 +46,13 @@ export class RunFinalizer {
|
|
|
35
46
|
void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
|
|
36
47
|
void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
|
|
37
48
|
this.linearSync.clearProgress(run.id);
|
|
38
|
-
|
|
49
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
39
50
|
return;
|
|
40
51
|
}
|
|
41
|
-
const missingReviewFixHeadError = await
|
|
52
|
+
const missingReviewFixHeadError = await this.completionPolicy.verifyReviewFixAdvancedHead(run, freshIssue);
|
|
42
53
|
if (missingReviewFixHeadError) {
|
|
43
|
-
|
|
44
|
-
const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
54
|
+
this.failRunAndClear(run, missingReviewFixHeadError, "escalated");
|
|
55
|
+
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
45
56
|
this.feed?.publish({
|
|
46
57
|
level: "error",
|
|
47
58
|
kind: "turn",
|
|
@@ -54,13 +65,13 @@ export class RunFinalizer {
|
|
|
54
65
|
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, missingReviewFixHeadError));
|
|
55
66
|
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
56
67
|
this.linearSync.clearProgress(run.id);
|
|
57
|
-
|
|
68
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
58
69
|
return;
|
|
59
70
|
}
|
|
60
|
-
const publishedOutcomeError = await
|
|
71
|
+
const publishedOutcomeError = await this.completionPolicy.verifyPublishedRunOutcome(run, freshIssue);
|
|
61
72
|
if (publishedOutcomeError) {
|
|
62
|
-
|
|
63
|
-
const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
73
|
+
this.failRunAndClear(run, publishedOutcomeError, "failed");
|
|
74
|
+
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
64
75
|
this.feed?.publish({
|
|
65
76
|
level: "warn",
|
|
66
77
|
kind: "turn",
|
|
@@ -74,14 +85,14 @@ export class RunFinalizer {
|
|
|
74
85
|
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
75
86
|
this.linearSync.clearProgress(run.id);
|
|
76
87
|
if (params.source === "notification") {
|
|
77
|
-
|
|
88
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
78
89
|
}
|
|
79
90
|
return;
|
|
80
91
|
}
|
|
81
|
-
const refreshedIssue = await
|
|
82
|
-
const postRunFollowUp = await
|
|
83
|
-
const postRunState = postRunFollowUp?.factoryState ??
|
|
84
|
-
const completed =
|
|
92
|
+
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
|
93
|
+
const postRunFollowUp = await this.completionPolicy.resolvePostRunFollowUp(run, refreshedIssue);
|
|
94
|
+
const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
|
|
95
|
+
const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
85
96
|
this.db.runs.finishRun(run.id, {
|
|
86
97
|
status: "completed",
|
|
87
98
|
threadId,
|
|
@@ -89,7 +100,7 @@ export class RunFinalizer {
|
|
|
89
100
|
summaryJson: JSON.stringify({ latestAssistantMessage: report.assistantMessages.at(-1) ?? null }),
|
|
90
101
|
reportJson: JSON.stringify(report),
|
|
91
102
|
});
|
|
92
|
-
this.db.upsertIssue({
|
|
103
|
+
this.db.issues.upsertIssue({
|
|
93
104
|
projectId: run.projectId,
|
|
94
105
|
linearIssueId: run.linearIssueId,
|
|
95
106
|
activeRunId: null,
|
|
@@ -112,14 +123,14 @@ export class RunFinalizer {
|
|
|
112
123
|
: {})),
|
|
113
124
|
});
|
|
114
125
|
if (postRunFollowUp) {
|
|
115
|
-
return
|
|
126
|
+
return this.appendWakeEventWithLease(lease, issue, postRunFollowUp.pendingRunType, postRunFollowUp.context, "post_run");
|
|
116
127
|
}
|
|
117
128
|
return true;
|
|
118
129
|
});
|
|
119
130
|
if (!completed) {
|
|
120
131
|
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion writes after losing issue-session lease");
|
|
121
132
|
this.linearSync.clearProgress(run.id);
|
|
122
|
-
|
|
133
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
123
134
|
return;
|
|
124
135
|
}
|
|
125
136
|
if (postRunFollowUp) {
|
|
@@ -146,7 +157,7 @@ export class RunFinalizer {
|
|
|
146
157
|
: `Reconciliation: ${run.runType} completed${postRunState ? ` -> ${postRunState}` : ""}`,
|
|
147
158
|
...(report.assistantMessages.at(-1) ? { detail: report.assistantMessages.at(-1) } : {}),
|
|
148
159
|
});
|
|
149
|
-
const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
160
|
+
const updatedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
150
161
|
const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
|
|
151
162
|
void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
|
|
152
163
|
runType: run.runType,
|
|
@@ -156,6 +167,6 @@ export class RunFinalizer {
|
|
|
156
167
|
}));
|
|
157
168
|
void this.linearSync.syncSession(updatedIssue);
|
|
158
169
|
this.linearSync.clearProgress(run.id);
|
|
159
|
-
|
|
170
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
160
171
|
}
|
|
161
172
|
}
|
package/dist/run-launcher.js
CHANGED
|
@@ -61,7 +61,7 @@ export class RunLauncher {
|
|
|
61
61
|
}
|
|
62
62
|
claimRun(params) {
|
|
63
63
|
return this.db.issueSessions.withIssueSessionLease(params.item.projectId, params.item.issueId, params.leaseId, () => {
|
|
64
|
-
const fresh = this.db.getIssue(params.item.projectId, params.item.issueId);
|
|
64
|
+
const fresh = this.db.issues.getIssue(params.item.projectId, params.item.issueId);
|
|
65
65
|
if (!fresh || fresh.activeRunId !== undefined)
|
|
66
66
|
return undefined;
|
|
67
67
|
const wakeIssue = params.materializeLegacyPendingWake(fresh, {
|
|
@@ -84,7 +84,7 @@ export class RunLauncher {
|
|
|
84
84
|
? params.effectiveContext.failureHeadSha
|
|
85
85
|
: typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
|
|
86
86
|
const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
|
|
87
|
-
this.db.upsertIssue({
|
|
87
|
+
this.db.issues.upsertIssue({
|
|
88
88
|
projectId: params.item.projectId,
|
|
89
89
|
linearIssueId: params.item.issueId,
|
|
90
90
|
pendingRunType: null,
|
|
@@ -123,9 +123,9 @@ export class RunLauncher {
|
|
|
123
123
|
const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=$(cat ${params.botIdentity.tokenFile})"; }; f`;
|
|
124
124
|
await execCommand(gitBin, ["-C", params.worktreePath, "config", "credential.helper", credentialHelper], { timeoutMs: 5_000 });
|
|
125
125
|
}
|
|
126
|
-
await
|
|
126
|
+
await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
|
|
127
127
|
if (params.runType !== "queue_repair") {
|
|
128
|
-
await
|
|
128
|
+
await this.worktreeManager.freshenWorktree(params.worktreePath, params.project, params.issue, this.logger);
|
|
129
129
|
}
|
|
130
130
|
const hookEnv = buildHookEnv(params.issue.issueKey ?? params.issue.linearIssueId, params.branchName, params.runType, params.worktreePath);
|
|
131
131
|
const prepareResult = await runProjectHook(params.project.repoPath, "prepare-worktree", { cwd: params.worktreePath, env: hookEnv });
|
|
@@ -183,7 +183,7 @@ export class RunLauncher {
|
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
this.logger.error({ issueKey: params.issue.issueKey, runType: params.runType, error: message }, `Failed to launch ${params.runType} run`);
|
|
186
|
-
const failedIssue = this.db.getIssue(params.project.id, params.issue.linearIssueId) ?? params.issue;
|
|
186
|
+
const failedIssue = this.db.issues.getIssue(params.project.id, params.issue.linearIssueId) ?? params.issue;
|
|
187
187
|
void params.linearSync.emitActivity(failedIssue, buildRunFailureActivity(params.runType, `Failed to start ${params.lowerCaseFirst(message)}`));
|
|
188
188
|
void params.linearSync.syncSession(failedIssue, { activeRunType: params.runType });
|
|
189
189
|
params.releaseLease(params.project.id, params.issue.linearIssueId);
|