patchrelay 0.75.3 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-input-service.js +40 -26
- package/dist/build-info.json +3 -3
- package/dist/cli/data.js +3 -1
- package/dist/db/issue-session-store.js +44 -9
- package/dist/db/issue-store.js +11 -2
- package/dist/db/migrations.js +3 -0
- package/dist/factory-state.js +23 -0
- package/dist/github-webhook-reactive-run.js +15 -11
- package/dist/github-webhook-stack-coordination.js +8 -4
- package/dist/github-webhook-state-projector.js +204 -139
- package/dist/github-webhook-terminal-handler.js +37 -27
- package/dist/idle-reconciliation.js +122 -66
- package/dist/implementation-outcome-policy.js +5 -1
- package/dist/interrupted-run-recovery.js +46 -33
- package/dist/issue-session-projection-invalidator.js +9 -0
- package/dist/linear-agent-session-client.js +16 -8
- package/dist/linear-issue-projection.js +15 -11
- package/dist/linear-status-comment-sync.js +8 -4
- package/dist/linear-workflow-state-sync.js +9 -5
- package/dist/merged-linear-completion-reconciler.js +39 -17
- package/dist/no-pr-completion-check.js +51 -29
- package/dist/orchestration-parent-wake.js +15 -8
- package/dist/queue-health-monitor.js +17 -8
- package/dist/reactive-run-policy.js +5 -1
- package/dist/run-finalizer.js +61 -29
- package/dist/run-launcher.js +42 -12
- package/dist/run-notification-handler.js +19 -7
- package/dist/run-orchestrator.js +54 -20
- package/dist/run-reconciler.js +121 -50
- package/dist/run-recovery-service.js +70 -33
- package/dist/run-wake-planner.js +39 -29
- package/dist/service-issue-actions.js +45 -28
- package/dist/service-startup-recovery.js +61 -35
- package/dist/telemetry.js +9 -0
- package/dist/terminal-wake-reconciler.js +20 -3
- package/dist/webhooks/agent-session-handler.js +22 -12
- package/dist/webhooks/dependency-readiness-handler.js +17 -10
- package/dist/webhooks/desired-stage-recorder.js +32 -13
- package/dist/webhooks/issue-removal-handler.js +24 -13
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
|
3
3
|
import { isCompletedLinearState } from "./pr-state.js";
|
|
4
4
|
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
5
5
|
import { replaceIssueDependenciesFromLinearIssue } from "./linear-issue-projection.js";
|
|
6
|
+
const WRITER = "merged-linear-completion-reconciler";
|
|
6
7
|
const COMPLETION_RECONCILE_WINDOW_MS = 60 * 60 * 1000;
|
|
7
8
|
const COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS = 60 * 60 * 1000;
|
|
8
9
|
const COMPLETION_RECONCILE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
@@ -88,23 +89,41 @@ export class MergedLinearCompletionReconciler {
|
|
|
88
89
|
return;
|
|
89
90
|
}
|
|
90
91
|
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
91
|
-
this.db.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
this.db.issueSessions.commitIssueState({
|
|
93
|
+
writer: WRITER,
|
|
94
|
+
update: {
|
|
95
|
+
projectId: issue.projectId,
|
|
96
|
+
linearIssueId: issue.linearIssueId,
|
|
97
|
+
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
98
|
+
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
99
|
+
},
|
|
96
100
|
});
|
|
97
101
|
}
|
|
98
102
|
reopenStaleLocalDoneIssue(issue, liveIssue) {
|
|
103
|
+
const buildReopenUpdate = (record) => {
|
|
104
|
+
const restored = resolveOpenWorkflowState(record);
|
|
105
|
+
return {
|
|
106
|
+
projectId: issue.projectId,
|
|
107
|
+
linearIssueId: issue.linearIssueId,
|
|
108
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
109
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
110
|
+
...(restored ? { factoryState: restored.factoryState } : {}),
|
|
111
|
+
...(restored ? { pendingRunType: restored.pendingRunType } : {}),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
99
114
|
const restored = resolveOpenWorkflowState(issue);
|
|
100
|
-
this.db.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
116
|
+
writer: WRITER,
|
|
117
|
+
expectedVersion: issue.version,
|
|
118
|
+
update: buildReopenUpdate(issue),
|
|
119
|
+
// Reopening a local done state must be re-derived against the fresh
|
|
120
|
+
// row when something else wrote in between — and only if it is
|
|
121
|
+
// still done.
|
|
122
|
+
onConflict: (current) => (current.factoryState === "done" ? buildReopenUpdate(current) : undefined),
|
|
107
123
|
});
|
|
124
|
+
if (commit.outcome !== "applied") {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
108
127
|
this.logger.info({
|
|
109
128
|
issueKey: issue.issueKey,
|
|
110
129
|
previousFactoryState: issue.factoryState,
|
|
@@ -116,11 +135,14 @@ export class MergedLinearCompletionReconciler {
|
|
|
116
135
|
if (issue.currentLinearState === liveIssue.stateName && issue.currentLinearStateType === liveIssue.stateType) {
|
|
117
136
|
return;
|
|
118
137
|
}
|
|
119
|
-
this.db.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
this.db.issueSessions.commitIssueState({
|
|
139
|
+
writer: WRITER,
|
|
140
|
+
update: {
|
|
141
|
+
projectId: issue.projectId,
|
|
142
|
+
linearIssueId: issue.linearIssueId,
|
|
143
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
144
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
145
|
+
},
|
|
124
146
|
});
|
|
125
147
|
}
|
|
126
148
|
isRecentCompletionCandidate(issue, now) {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
|
|
2
2
|
import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
|
|
4
|
+
const WRITER = "no-pr-completion-check";
|
|
5
|
+
// Post-completion-check decision writes all clear the run slot; on a version
|
|
6
|
+
// conflict, apply only if the slot still belongs to this run on the fresh row.
|
|
7
|
+
function commitRunSlotUpdate(db, run, issue, update) {
|
|
8
|
+
const commit = db.issueSessions.commitIssueState({
|
|
9
|
+
writer: WRITER,
|
|
10
|
+
expectedVersion: issue.version,
|
|
11
|
+
update,
|
|
12
|
+
onConflict: (current) => (current.activeRunId === run.id ? update : undefined),
|
|
13
|
+
});
|
|
14
|
+
return commit.outcome === "applied";
|
|
15
|
+
}
|
|
4
16
|
function shouldContinueForUnpublishedLocalChanges(message) {
|
|
5
17
|
const normalized = message.trim().toLowerCase();
|
|
6
18
|
if (!normalized)
|
|
@@ -56,16 +68,18 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
56
68
|
}
|
|
57
69
|
if (completionCheck.outcome === "continue") {
|
|
58
70
|
const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
59
|
-
params.db
|
|
60
|
-
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
61
|
-
params.db.issues.upsertIssue({
|
|
71
|
+
if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
|
|
62
72
|
projectId: params.run.projectId,
|
|
63
73
|
linearIssueId: params.run.linearIssueId,
|
|
64
74
|
activeRunId: null,
|
|
65
75
|
factoryState: "delegated",
|
|
66
76
|
pendingRunType: null,
|
|
67
77
|
pendingRunContextJson: null,
|
|
68
|
-
})
|
|
78
|
+
})) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
params.db.runs.finishRun(params.run.id, runUpdate);
|
|
82
|
+
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
69
83
|
return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
70
84
|
projectId: params.run.projectId,
|
|
71
85
|
linearIssueId: params.run.linearIssueId,
|
|
@@ -95,17 +109,19 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
95
109
|
}
|
|
96
110
|
if (completionCheck.outcome === "needs_input") {
|
|
97
111
|
const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
98
|
-
params.db
|
|
99
|
-
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
100
|
-
params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
101
|
-
params.db.issues.upsertIssue({
|
|
112
|
+
if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
|
|
102
113
|
projectId: params.run.projectId,
|
|
103
114
|
linearIssueId: params.run.linearIssueId,
|
|
104
115
|
activeRunId: null,
|
|
105
116
|
factoryState: "awaiting_input",
|
|
106
117
|
pendingRunType: null,
|
|
107
118
|
pendingRunContextJson: null,
|
|
108
|
-
})
|
|
119
|
+
})) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
params.db.runs.finishRun(params.run.id, runUpdate);
|
|
123
|
+
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
124
|
+
params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
109
125
|
return true;
|
|
110
126
|
});
|
|
111
127
|
if (!completed) {
|
|
@@ -127,20 +143,22 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
127
143
|
if (completionCheck.outcome === "done") {
|
|
128
144
|
if (shouldContinueForUnpublishedLocalChanges(params.publishedOutcomeError)) {
|
|
129
145
|
const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
130
|
-
params.db
|
|
131
|
-
params.db.runs.saveCompletionCheck(params.run.id, {
|
|
132
|
-
...completionCheck,
|
|
133
|
-
outcome: "continue",
|
|
134
|
-
summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
|
|
135
|
-
why: params.publishedOutcomeError,
|
|
136
|
-
});
|
|
137
|
-
params.db.issues.upsertIssue({
|
|
146
|
+
if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
|
|
138
147
|
projectId: params.run.projectId,
|
|
139
148
|
linearIssueId: params.run.linearIssueId,
|
|
140
149
|
activeRunId: null,
|
|
141
150
|
factoryState: "delegated",
|
|
142
151
|
pendingRunType: null,
|
|
143
152
|
pendingRunContextJson: null,
|
|
153
|
+
})) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
params.db.runs.finishRun(params.run.id, runUpdate);
|
|
157
|
+
params.db.runs.saveCompletionCheck(params.run.id, {
|
|
158
|
+
...completionCheck,
|
|
159
|
+
outcome: "continue",
|
|
160
|
+
summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
|
|
161
|
+
why: params.publishedOutcomeError,
|
|
144
162
|
});
|
|
145
163
|
return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
146
164
|
projectId: params.run.projectId,
|
|
@@ -173,10 +191,7 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
173
191
|
? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
|
|
174
192
|
: 0;
|
|
175
193
|
const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
176
|
-
params.db
|
|
177
|
-
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
178
|
-
params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
179
|
-
params.db.issues.upsertIssue({
|
|
194
|
+
if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
|
|
180
195
|
projectId: params.run.projectId,
|
|
181
196
|
linearIssueId: params.run.linearIssueId,
|
|
182
197
|
activeRunId: null,
|
|
@@ -185,7 +200,12 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
185
200
|
pendingRunContextJson: null,
|
|
186
201
|
orchestrationSettleUntil: null,
|
|
187
202
|
...CLEARED_FAILURE_PROVENANCE,
|
|
188
|
-
})
|
|
203
|
+
})) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
params.db.runs.finishRun(params.run.id, runUpdate);
|
|
207
|
+
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
208
|
+
params.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
189
209
|
return true;
|
|
190
210
|
});
|
|
191
211
|
if (!completed) {
|
|
@@ -217,20 +237,22 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
217
237
|
}
|
|
218
238
|
const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
|
|
219
239
|
const failed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, () => {
|
|
220
|
-
params.db
|
|
221
|
-
...runUpdate,
|
|
222
|
-
status: "failed",
|
|
223
|
-
failureReason,
|
|
224
|
-
});
|
|
225
|
-
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
226
|
-
params.db.issues.upsertIssue({
|
|
240
|
+
if (!commitRunSlotUpdate(params.db, params.run, params.issue, {
|
|
227
241
|
projectId: params.run.projectId,
|
|
228
242
|
linearIssueId: params.run.linearIssueId,
|
|
229
243
|
activeRunId: null,
|
|
230
244
|
factoryState: "failed",
|
|
231
245
|
pendingRunType: null,
|
|
232
246
|
pendingRunContextJson: null,
|
|
247
|
+
})) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
params.db.runs.finishRun(params.run.id, {
|
|
251
|
+
...runUpdate,
|
|
252
|
+
status: "failed",
|
|
253
|
+
failureReason,
|
|
233
254
|
});
|
|
255
|
+
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
234
256
|
return true;
|
|
235
257
|
});
|
|
236
258
|
if (!failed) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyIssue } from "./issue-class.js";
|
|
2
|
+
const WRITER = "orchestration-parent-wake";
|
|
2
3
|
export const ORCHESTRATION_SETTLE_WINDOW_MS = 10_000;
|
|
3
4
|
export function computeOrchestrationSettleUntil(now = Date.now()) {
|
|
4
5
|
return new Date(now + ORCHESTRATION_SETTLE_WINDOW_MS).toISOString();
|
|
@@ -24,18 +25,24 @@ function resolveParentIssueIds(db, child) {
|
|
|
24
25
|
}
|
|
25
26
|
export function startOrchestrationSettleWindow(db, issue, now = Date.now()) {
|
|
26
27
|
const settleUntil = computeOrchestrationSettleUntil(now);
|
|
27
|
-
db.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
db.issueSessions.commitIssueState({
|
|
29
|
+
writer: WRITER,
|
|
30
|
+
update: {
|
|
31
|
+
projectId: issue.projectId,
|
|
32
|
+
linearIssueId: issue.linearIssueId,
|
|
33
|
+
orchestrationSettleUntil: settleUntil,
|
|
34
|
+
},
|
|
31
35
|
});
|
|
32
36
|
return settleUntil;
|
|
33
37
|
}
|
|
34
38
|
export function queueSettledOrchestrationIssue(params) {
|
|
35
|
-
params.db.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
params.db.issueSessions.commitIssueState({
|
|
40
|
+
writer: WRITER,
|
|
41
|
+
update: {
|
|
42
|
+
projectId: params.issue.projectId,
|
|
43
|
+
linearIssueId: params.issue.linearIssueId,
|
|
44
|
+
orchestrationSettleUntil: null,
|
|
45
|
+
},
|
|
39
46
|
});
|
|
40
47
|
const dispatched = params.wakeDispatcher.recordEventAndDispatch(params.issue.projectId, params.issue.linearIssueId, {
|
|
41
48
|
eventType: "delegated",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
2
|
import { buildRepairWakeDedupeKey } from "./reactive-wake-keys.js";
|
|
3
3
|
import { execCommand } from "./utils.js";
|
|
4
|
+
const WRITER = "queue-health-monitor";
|
|
4
5
|
const QUEUE_HEALTH_GRACE_MS = 120_000;
|
|
5
6
|
const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
|
|
6
7
|
// Plan §6.2: an approved PR with red branch CI for >= this long is
|
|
@@ -113,8 +114,12 @@ export class QueueHealthMonitor {
|
|
|
113
114
|
}
|
|
114
115
|
this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
|
|
115
116
|
if (pr.state === "MERGED") {
|
|
116
|
-
this.db.
|
|
117
|
-
|
|
117
|
+
const mergedCommit = this.db.issueSessions.commitIssueState({
|
|
118
|
+
writer: WRITER,
|
|
119
|
+
update: { projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" },
|
|
120
|
+
});
|
|
121
|
+
const merged = mergedCommit.outcome === "applied" ? mergedCommit.issue : issue;
|
|
122
|
+
this.advancer.advanceIdleIssue(merged, "done", { clearFailureProvenance: true });
|
|
118
123
|
return;
|
|
119
124
|
}
|
|
120
125
|
if (pr.state !== "OPEN")
|
|
@@ -159,12 +164,16 @@ export class QueueHealthMonitor {
|
|
|
159
164
|
if (isDuplicateProbe(issue, pendingRunContext)) {
|
|
160
165
|
return;
|
|
161
166
|
}
|
|
162
|
-
this.db.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
const probedCommit = this.db.issueSessions.commitIssueState({
|
|
168
|
+
writer: WRITER,
|
|
169
|
+
update: {
|
|
170
|
+
projectId: issue.projectId,
|
|
171
|
+
linearIssueId: issue.linearIssueId,
|
|
172
|
+
lastAttemptedFailureHeadSha: headRefOid,
|
|
173
|
+
lastAttemptedFailureSignature: signature,
|
|
174
|
+
},
|
|
167
175
|
});
|
|
176
|
+
const probed = probedCommit.outcome === "applied" ? probedCommit.issue : issue;
|
|
168
177
|
this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
169
178
|
eventType: "merge_steward_incident",
|
|
170
179
|
eventJson: JSON.stringify(pendingRunContext),
|
|
@@ -175,7 +184,7 @@ export class QueueHealthMonitor {
|
|
|
175
184
|
signature,
|
|
176
185
|
}),
|
|
177
186
|
});
|
|
178
|
-
this.advancer.advanceIdleIssue(
|
|
187
|
+
this.advancer.advanceIdleIssue(probed, "repairing_queue");
|
|
179
188
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
|
|
180
189
|
this.feed?.publish({
|
|
181
190
|
level: "warn",
|
|
@@ -2,6 +2,7 @@ import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
|
|
|
2
2
|
import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
|
|
3
3
|
import { readReactivePublishDelta } from "./reactive-publish-delta.js";
|
|
4
4
|
import { readLatestRequestedChangesReviewContext } from "./remote-pr-review.js";
|
|
5
|
+
const WRITER = "reactive-run-policy";
|
|
5
6
|
const REACTIVE_SCOPE_RISK_PREFIXES = [
|
|
6
7
|
".github/workflows/",
|
|
7
8
|
"scripts/bootstrap-worktree.",
|
|
@@ -273,7 +274,10 @@ export class ReactiveRunPolicy {
|
|
|
273
274
|
}
|
|
274
275
|
}
|
|
275
276
|
upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
|
|
276
|
-
const updated = this.withHeldLease(projectId, linearIssueId, (lease) =>
|
|
277
|
+
const updated = this.withHeldLease(projectId, linearIssueId, (lease) => {
|
|
278
|
+
const commit = this.db.issueSessions.commitIssueState({ writer: WRITER, lease, update: params });
|
|
279
|
+
return commit.outcome === "applied" ? commit.issue : undefined;
|
|
280
|
+
});
|
|
277
281
|
if (updated === undefined) {
|
|
278
282
|
this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
|
|
279
283
|
}
|
package/dist/run-finalizer.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
|
6
6
|
import { computeChangeIdentityFromWorktree } from "./change-identity.js";
|
|
7
7
|
import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
|
|
8
8
|
import { buildRunOutcomeSummary } from "./run-outcome-summary.js";
|
|
9
|
+
const WRITER = "run-finalizer";
|
|
9
10
|
function parseEventJson(eventJson) {
|
|
10
11
|
if (!eventJson)
|
|
11
12
|
return undefined;
|
|
@@ -150,12 +151,16 @@ export class RunFinalizer {
|
|
|
150
151
|
});
|
|
151
152
|
if (!identity.patchId && !identity.integrationTreeId)
|
|
152
153
|
return;
|
|
153
|
-
this.db.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
this.db.issueSessions.commitIssueState({
|
|
155
|
+
writer: WRITER,
|
|
156
|
+
expectedVersion: issue.version,
|
|
157
|
+
update: {
|
|
158
|
+
projectId: issue.projectId,
|
|
159
|
+
linearIssueId: issue.linearIssueId,
|
|
160
|
+
...(identity.patchId ? { lastPublishedPatchId: identity.patchId } : {}),
|
|
161
|
+
...(identity.integrationTreeId ? { lastPublishedIntegrationTreeId: identity.integrationTreeId } : {}),
|
|
162
|
+
lastPublishedHeadSha: issue.prHeadSha,
|
|
163
|
+
},
|
|
159
164
|
});
|
|
160
165
|
this.logger.info({
|
|
161
166
|
issueKey: issue.issueKey,
|
|
@@ -194,12 +199,15 @@ export class RunFinalizer {
|
|
|
194
199
|
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
195
200
|
failureReason: run.failureReason ?? "approved on the same head; further publication suppressed",
|
|
196
201
|
});
|
|
197
|
-
this.db.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
this.db.issueSessions.commitIssueState({
|
|
203
|
+
writer: WRITER,
|
|
204
|
+
update: {
|
|
205
|
+
projectId: run.projectId,
|
|
206
|
+
linearIssueId: run.linearIssueId,
|
|
207
|
+
activeRunId: null,
|
|
208
|
+
pendingRunType: null,
|
|
209
|
+
pendingRunContextJson: null,
|
|
210
|
+
},
|
|
203
211
|
});
|
|
204
212
|
});
|
|
205
213
|
this.clearProgressAndRelease(run);
|
|
@@ -274,23 +282,33 @@ export class RunFinalizer {
|
|
|
274
282
|
report: params.report,
|
|
275
283
|
outcomeSummary,
|
|
276
284
|
}));
|
|
277
|
-
|
|
285
|
+
// The attempt decrements are read-modify-write against the issue row;
|
|
286
|
+
// on conflict, recompute them from the fresh row instead of writing
|
|
287
|
+
// counters derived from a stale read.
|
|
288
|
+
const buildContinueUpdate = (record) => ({
|
|
278
289
|
projectId: params.run.projectId,
|
|
279
290
|
linearIssueId: params.run.linearIssueId,
|
|
280
291
|
activeRunId: null,
|
|
281
292
|
factoryState: "delegated",
|
|
282
293
|
pendingRunType: null,
|
|
283
294
|
pendingRunContextJson: null,
|
|
284
|
-
...(params.run.runType === "ci_repair" &&
|
|
285
|
-
? { ciRepairAttempts:
|
|
295
|
+
...(params.run.runType === "ci_repair" && record.ciRepairAttempts > 0
|
|
296
|
+
? { ciRepairAttempts: record.ciRepairAttempts - 1 }
|
|
286
297
|
: {}),
|
|
287
|
-
...(params.run.runType === "queue_repair" &&
|
|
288
|
-
? { queueRepairAttempts:
|
|
298
|
+
...(params.run.runType === "queue_repair" && record.queueRepairAttempts > 0
|
|
299
|
+
? { queueRepairAttempts: record.queueRepairAttempts - 1 }
|
|
289
300
|
: {}),
|
|
290
|
-
...((params.run.runType === "review_fix" || params.run.runType === "branch_upkeep") &&
|
|
291
|
-
? { reviewFixAttempts:
|
|
301
|
+
...((params.run.runType === "review_fix" || params.run.runType === "branch_upkeep") && record.reviewFixAttempts > 0
|
|
302
|
+
? { reviewFixAttempts: record.reviewFixAttempts - 1 }
|
|
292
303
|
: {}),
|
|
293
304
|
});
|
|
305
|
+
this.db.issueSessions.commitIssueState({
|
|
306
|
+
writer: WRITER,
|
|
307
|
+
lease,
|
|
308
|
+
expectedVersion: params.issue.version,
|
|
309
|
+
update: buildContinueUpdate(params.issue),
|
|
310
|
+
onConflict: (current) => buildContinueUpdate(current),
|
|
311
|
+
});
|
|
294
312
|
return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
295
313
|
projectId: params.run.projectId,
|
|
296
314
|
linearIssueId: params.run.linearIssueId,
|
|
@@ -435,23 +453,37 @@ export class RunFinalizer {
|
|
|
435
453
|
postRunState,
|
|
436
454
|
latestAssistantSummary: report.assistantMessages.at(-1),
|
|
437
455
|
});
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
this.db.issues.upsertIssue({
|
|
456
|
+
// `refreshedIssue` was read before several async policy checks; a
|
|
457
|
+
// version conflict here means a webhook landed mid-finalize. Re-resolve
|
|
458
|
+
// the post-run state from the fresh row so we never regress it (e.g.
|
|
459
|
+
// the PR merged while we were verifying the publish).
|
|
460
|
+
const buildCompletionUpdate = (record) => {
|
|
461
|
+
const state = postRunFollowUp?.factoryState ?? resolveCompletedRunState(record, run);
|
|
462
|
+
return {
|
|
446
463
|
projectId: run.projectId,
|
|
447
464
|
linearIssueId: run.linearIssueId,
|
|
448
465
|
activeRunId: null,
|
|
449
|
-
...(
|
|
466
|
+
...(state ? { factoryState: state } : {}),
|
|
450
467
|
pendingRunType: null,
|
|
451
468
|
pendingRunContextJson: null,
|
|
452
|
-
...(postRunFollowUp ? {} : (
|
|
469
|
+
...(postRunFollowUp ? {} : (state === "awaiting_queue" || state === "done"
|
|
453
470
|
? { ...CLEARED_FAILURE_PROVENANCE }
|
|
454
471
|
: {})),
|
|
472
|
+
};
|
|
473
|
+
};
|
|
474
|
+
const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
475
|
+
this.db.runs.finishRun(run.id, this.buildCompletedRunUpdate({
|
|
476
|
+
threadId,
|
|
477
|
+
...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
|
|
478
|
+
report,
|
|
479
|
+
outcomeSummary,
|
|
480
|
+
}));
|
|
481
|
+
this.db.issueSessions.commitIssueState({
|
|
482
|
+
writer: WRITER,
|
|
483
|
+
lease,
|
|
484
|
+
expectedVersion: refreshedIssue.version,
|
|
485
|
+
update: buildCompletionUpdate(refreshedIssue),
|
|
486
|
+
onConflict: (current) => buildCompletionUpdate(current),
|
|
455
487
|
});
|
|
456
488
|
if (postRunFollowUp) {
|
|
457
489
|
return this.appendWakeEventWithLease(lease, issue, postRunFollowUp.pendingRunType, postRunFollowUp.context, "post_run");
|
package/dist/run-launcher.js
CHANGED
|
@@ -4,6 +4,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
|
4
4
|
import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
|
|
5
5
|
import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
|
|
6
6
|
import { sanitizeDiagnosticText } from "./utils.js";
|
|
7
|
+
const WRITER = "run-launcher";
|
|
7
8
|
function slugify(value) {
|
|
8
9
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
9
10
|
}
|
|
@@ -128,7 +129,7 @@ export class RunLauncher {
|
|
|
128
129
|
? params.effectiveContext.failureHeadSha
|
|
129
130
|
: typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
|
|
130
131
|
const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
|
|
131
|
-
|
|
132
|
+
const claimUpdate = {
|
|
132
133
|
projectId: params.item.projectId,
|
|
133
134
|
linearIssueId: params.item.issueId,
|
|
134
135
|
pendingRunType: null,
|
|
@@ -148,7 +149,18 @@ export class RunLauncher {
|
|
|
148
149
|
lastAttemptedFailureAt: new Date().toISOString(),
|
|
149
150
|
}
|
|
150
151
|
: {}),
|
|
152
|
+
};
|
|
153
|
+
const claimCommit = this.db.issueSessions.commitIssueState({
|
|
154
|
+
writer: WRITER,
|
|
155
|
+
// `wakeIssue` is the freshest row this claim transaction has seen
|
|
156
|
+
// (materializeLegacyPendingWake may have bumped the version).
|
|
157
|
+
expectedVersion: wakeIssue.version,
|
|
158
|
+
update: claimUpdate,
|
|
159
|
+
// Never steal a slot another writer claimed concurrently.
|
|
160
|
+
onConflict: (current) => (current.activeRunId == null ? claimUpdate : undefined),
|
|
151
161
|
});
|
|
162
|
+
if (claimCommit.outcome !== "applied")
|
|
163
|
+
return undefined;
|
|
152
164
|
this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
|
|
153
165
|
this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
|
|
154
166
|
return created;
|
|
@@ -201,7 +213,11 @@ export class RunLauncher {
|
|
|
201
213
|
const thread = await this.codex.startThread({ cwd: params.worktreePath });
|
|
202
214
|
threadId = thread.id;
|
|
203
215
|
createdThreadForRun = true;
|
|
204
|
-
this.db.issueSessions.
|
|
216
|
+
this.db.issueSessions.commitIssueState({
|
|
217
|
+
writer: WRITER,
|
|
218
|
+
lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
|
|
219
|
+
update: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId },
|
|
220
|
+
});
|
|
205
221
|
}
|
|
206
222
|
this.db.runs.updateLaunchPhase(params.run.id, "thread_started");
|
|
207
223
|
try {
|
|
@@ -216,7 +232,11 @@ export class RunLauncher {
|
|
|
216
232
|
const thread = await this.codex.startThread({ cwd: params.worktreePath });
|
|
217
233
|
threadId = thread.id;
|
|
218
234
|
createdThreadForRun = true;
|
|
219
|
-
this.db.issueSessions.
|
|
235
|
+
this.db.issueSessions.commitIssueState({
|
|
236
|
+
writer: WRITER,
|
|
237
|
+
lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
|
|
238
|
+
update: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId },
|
|
239
|
+
});
|
|
220
240
|
const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
|
|
221
241
|
turnId = turn.turnId;
|
|
222
242
|
this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
|
|
@@ -236,15 +256,25 @@ export class RunLauncher {
|
|
|
236
256
|
const lostLease = error instanceof Error && error.name === "IssueSessionLeaseLostError";
|
|
237
257
|
if (!lostLease) {
|
|
238
258
|
const nextState = resolveFailureFactoryState(params.runType);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
// Issue clear + run-terminal write ride in one transaction; the run
|
|
260
|
+
// finish is gated on the issue commit so a lost lease skips both.
|
|
261
|
+
this.db.transaction(() => {
|
|
262
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
263
|
+
writer: WRITER,
|
|
264
|
+
lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId },
|
|
265
|
+
update: {
|
|
266
|
+
projectId: params.project.id,
|
|
267
|
+
linearIssueId: params.issue.linearIssueId,
|
|
268
|
+
activeRunId: null,
|
|
269
|
+
factoryState: nextState,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (commit.outcome !== "applied")
|
|
273
|
+
return;
|
|
274
|
+
this.db.runs.finishRun(params.run.id, {
|
|
275
|
+
status: "failed",
|
|
276
|
+
failureReason: message,
|
|
277
|
+
});
|
|
248
278
|
});
|
|
249
279
|
}
|
|
250
280
|
this.logger.error({ issueKey: params.issue.issueKey, runType: params.runType, error: message }, `Failed to launch ${params.runType} run`);
|
|
@@ -2,6 +2,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
|
2
2
|
import { extractTurnId, resolveRunCompletionStatus } from "./run-reporting.js";
|
|
3
3
|
import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
|
|
4
4
|
import { resolveFailureFactoryState } from "./reactive-pr-state.js";
|
|
5
|
+
const WRITER = "run-notification-handler";
|
|
5
6
|
const DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
7
|
export class RunNotificationHandler {
|
|
7
8
|
config;
|
|
@@ -90,19 +91,30 @@ export class RunNotificationHandler {
|
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
const nextState = resolveFailureFactoryState(run.runType);
|
|
94
|
+
const failureUpdate = {
|
|
95
|
+
projectId: run.projectId,
|
|
96
|
+
linearIssueId: run.linearIssueId,
|
|
97
|
+
activeRunId: null,
|
|
98
|
+
factoryState: nextState,
|
|
99
|
+
};
|
|
93
100
|
const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
|
|
94
|
-
this.db.issueSessions.
|
|
101
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
102
|
+
writer: WRITER,
|
|
103
|
+
lease,
|
|
104
|
+
// The issue row was read before awaiting the failed-run recovery;
|
|
105
|
+
// only clear the slot if it still belongs to this run.
|
|
106
|
+
expectedVersion: issue.version,
|
|
107
|
+
update: failureUpdate,
|
|
108
|
+
onConflict: (current) => (current.activeRunId === run.id ? failureUpdate : undefined),
|
|
109
|
+
});
|
|
110
|
+
if (commit.outcome !== "applied")
|
|
111
|
+
return false;
|
|
112
|
+
this.db.runs.finishRun(run.id, {
|
|
95
113
|
status: "failed",
|
|
96
114
|
threadId,
|
|
97
115
|
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
98
116
|
failureReason,
|
|
99
117
|
});
|
|
100
|
-
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
101
|
-
projectId: run.projectId,
|
|
102
|
-
linearIssueId: run.linearIssueId,
|
|
103
|
-
activeRunId: null,
|
|
104
|
-
factoryState: nextState,
|
|
105
|
-
});
|
|
106
118
|
return true;
|
|
107
119
|
});
|
|
108
120
|
if (!updated) {
|