patchrelay 0.75.3 → 0.77.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/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-budgets.js +40 -6
- package/dist/run-completion-policy.js +50 -9
- package/dist/run-failure-policy.js +463 -0
- package/dist/run-finalizer.js +68 -35
- package/dist/run-launcher.js +63 -12
- package/dist/run-notification-handler.js +19 -9
- package/dist/run-orchestrator.js +70 -78
- package/dist/run-reconciler.js +137 -64
- package/dist/run-settlement.js +57 -0
- 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
- package/dist/interrupted-run-recovery.js +0 -227
- package/dist/run-recovery-service.js +0 -202
- package/dist/zombie-recovery.js +0 -13
package/dist/run-reconciler.js
CHANGED
|
@@ -3,11 +3,12 @@ import { TERMINAL_STATES } from "./factory-state.js";
|
|
|
3
3
|
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
4
4
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
5
5
|
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
6
|
-
import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
|
|
7
6
|
import { resolveEffectiveActiveRun } from "./effective-active-run.js";
|
|
8
7
|
import { isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
9
8
|
import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
|
|
9
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
10
10
|
const THREAD_MATERIALIZATION_GRACE_MS = 10 * 60_000;
|
|
11
|
+
const WRITER = "run-reconciler";
|
|
11
12
|
function isWithinThreadMaterializationGrace(run, nowMs = Date.now()) {
|
|
12
13
|
const startedAtMs = Date.parse(run.startedAt);
|
|
13
14
|
if (!Number.isFinite(startedAtMs))
|
|
@@ -19,27 +20,27 @@ export class RunReconciler {
|
|
|
19
20
|
logger;
|
|
20
21
|
linearProvider;
|
|
21
22
|
linearSync;
|
|
22
|
-
|
|
23
|
+
failurePolicy;
|
|
23
24
|
runFinalizer;
|
|
24
25
|
withHeldLease;
|
|
25
26
|
releaseLease;
|
|
26
27
|
readThreadWithRetry;
|
|
27
|
-
recoverOrEscalate;
|
|
28
28
|
resolveRepoFullName;
|
|
29
29
|
feed;
|
|
30
|
-
|
|
30
|
+
telemetry;
|
|
31
|
+
constructor(db, logger, linearProvider, linearSync, failurePolicy, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, resolveRepoFullName = () => undefined, feed, telemetry = noopTelemetry) {
|
|
31
32
|
this.db = db;
|
|
32
33
|
this.logger = logger;
|
|
33
34
|
this.linearProvider = linearProvider;
|
|
34
35
|
this.linearSync = linearSync;
|
|
35
|
-
this.
|
|
36
|
+
this.failurePolicy = failurePolicy;
|
|
36
37
|
this.runFinalizer = runFinalizer;
|
|
37
38
|
this.withHeldLease = withHeldLease;
|
|
38
39
|
this.releaseLease = releaseLease;
|
|
39
40
|
this.readThreadWithRetry = readThreadWithRetry;
|
|
40
|
-
this.recoverOrEscalate = recoverOrEscalate;
|
|
41
41
|
this.resolveRepoFullName = resolveRepoFullName;
|
|
42
42
|
this.feed = feed;
|
|
43
|
+
this.telemetry = telemetry;
|
|
43
44
|
}
|
|
44
45
|
async reconcile(params) {
|
|
45
46
|
const { run, issue, recoveryLease } = params;
|
|
@@ -50,13 +51,39 @@ export class RunReconciler {
|
|
|
50
51
|
latestRun: run,
|
|
51
52
|
});
|
|
52
53
|
if (effectiveActiveRun?.id === run.id && issue.activeRunId !== run.id) {
|
|
53
|
-
|
|
54
|
+
const reattachUpdate = {
|
|
54
55
|
projectId: run.projectId,
|
|
55
56
|
linearIssueId: run.linearIssueId,
|
|
56
57
|
activeRunId: run.id,
|
|
57
58
|
...(run.threadId ? { threadId: run.threadId } : {}),
|
|
58
|
-
}
|
|
59
|
-
this.
|
|
59
|
+
};
|
|
60
|
+
const commit = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issueSessions.commitIssueState({
|
|
61
|
+
writer: WRITER,
|
|
62
|
+
expectedVersion: issue.version,
|
|
63
|
+
update: reattachUpdate,
|
|
64
|
+
// Never steal the slot from a run that was attached concurrently.
|
|
65
|
+
onConflict: (current) => (current.activeRunId == null ? reattachUpdate : undefined),
|
|
66
|
+
}));
|
|
67
|
+
if (commit?.outcome === "applied") {
|
|
68
|
+
effectiveIssue = commit.issue;
|
|
69
|
+
this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Reattached detached active run during reconciliation");
|
|
70
|
+
// Plan §B5: with settleRun idempotent and the launcher persisting the
|
|
71
|
+
// thread id before startTurn, this reattachment should never fire.
|
|
72
|
+
// Telemetry observes it for one release before the block is deleted.
|
|
73
|
+
emitTelemetry(this.telemetry, {
|
|
74
|
+
type: "health.invariant",
|
|
75
|
+
invariant: "detached_active_run",
|
|
76
|
+
status: "repaired",
|
|
77
|
+
projectId: run.projectId,
|
|
78
|
+
linearIssueId: run.linearIssueId,
|
|
79
|
+
...(effectiveIssue.issueKey ? { issueKey: effectiveIssue.issueKey } : {}),
|
|
80
|
+
runId: run.id,
|
|
81
|
+
detail: `Reattached detached active ${run.runType} run during reconciliation`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else if (commit?.outcome === "conflict_skipped" && commit.issue) {
|
|
85
|
+
effectiveIssue = commit.issue;
|
|
86
|
+
}
|
|
60
87
|
}
|
|
61
88
|
if (!effectiveIssue.delegatedToPatchRelay) {
|
|
62
89
|
const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
|
|
@@ -69,9 +96,18 @@ export class RunReconciler {
|
|
|
69
96
|
}
|
|
70
97
|
}
|
|
71
98
|
if (TERMINAL_STATES.has(effectiveIssue.factoryState)) {
|
|
99
|
+
const terminalClear = { projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null };
|
|
72
100
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
101
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
102
|
+
writer: WRITER,
|
|
103
|
+
expectedVersion: effectiveIssue.version,
|
|
104
|
+
update: terminalClear,
|
|
105
|
+
// Re-check the release predicate against the fresh row.
|
|
106
|
+
onConflict: (current) => TERMINAL_STATES.has(current.factoryState) && current.activeRunId === run.id ? terminalClear : undefined,
|
|
107
|
+
});
|
|
108
|
+
if (commit.outcome !== "applied")
|
|
109
|
+
return;
|
|
73
110
|
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
|
|
74
|
-
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
75
111
|
});
|
|
76
112
|
this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, factoryState: effectiveIssue.factoryState }, "Reconciliation: released run on terminal issue");
|
|
77
113
|
const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
@@ -88,11 +124,14 @@ export class RunReconciler {
|
|
|
88
124
|
return;
|
|
89
125
|
}
|
|
90
126
|
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
// Detection only — the failure policy settles the run and decides
|
|
128
|
+
// retry vs escalate (plan §B4).
|
|
129
|
+
this.failurePolicy.settleStrandedRunAndRecover({
|
|
130
|
+
run,
|
|
131
|
+
issue: effectiveIssue,
|
|
132
|
+
reason: "zombie",
|
|
133
|
+
failureReason: "Zombie: never started (no thread after restart)",
|
|
94
134
|
});
|
|
95
|
-
this.recoverOrEscalate(effectiveIssue, run.runType, "zombie");
|
|
96
135
|
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
97
136
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
|
|
98
137
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
@@ -113,11 +152,14 @@ export class RunReconciler {
|
|
|
113
152
|
return;
|
|
114
153
|
}
|
|
115
154
|
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
155
|
+
// Detection only — the failure policy settles the run and decides
|
|
156
|
+
// retry vs escalate (plan §B4).
|
|
157
|
+
this.failurePolicy.settleStrandedRunAndRecover({
|
|
158
|
+
run,
|
|
159
|
+
issue: effectiveIssue,
|
|
160
|
+
reason: "stale_thread",
|
|
161
|
+
failureReason: "Stale thread after restart",
|
|
119
162
|
});
|
|
120
|
-
this.recoverOrEscalate(effectiveIssue, run.runType, "stale_thread");
|
|
121
163
|
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
122
164
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
|
|
123
165
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
@@ -130,15 +172,25 @@ export class RunReconciler {
|
|
|
130
172
|
if (linearIssue) {
|
|
131
173
|
const stopState = resolveAuthoritativeLinearStopState(linearIssue);
|
|
132
174
|
if (stopState?.isFinal) {
|
|
175
|
+
const stopUpdate = {
|
|
176
|
+
projectId: run.projectId,
|
|
177
|
+
linearIssueId: run.linearIssueId,
|
|
178
|
+
activeRunId: null,
|
|
179
|
+
currentLinearState: stopState.stateName,
|
|
180
|
+
factoryState: "done",
|
|
181
|
+
};
|
|
133
182
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
134
|
-
this.db.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
183
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
184
|
+
writer: WRITER,
|
|
185
|
+
expectedVersion: effectiveIssue.version,
|
|
186
|
+
// The Linear stop state is authoritative; only the run-slot
|
|
187
|
+
// ownership needs re-checking on conflict.
|
|
188
|
+
update: stopUpdate,
|
|
189
|
+
onConflict: (current) => (current.activeRunId === run.id ? stopUpdate : undefined),
|
|
141
190
|
});
|
|
191
|
+
if (commit.outcome !== "applied")
|
|
192
|
+
return;
|
|
193
|
+
this.db.runs.finishRun(run.id, { status: "released" });
|
|
142
194
|
});
|
|
143
195
|
this.feed?.publish({
|
|
144
196
|
level: "info",
|
|
@@ -158,7 +210,7 @@ export class RunReconciler {
|
|
|
158
210
|
}
|
|
159
211
|
const latestTurn = getThreadTurns(thread).at(-1);
|
|
160
212
|
if (latestTurn?.status === "interrupted") {
|
|
161
|
-
await this.
|
|
213
|
+
await this.failurePolicy.handleInterruptedRun(run, effectiveIssue);
|
|
162
214
|
return;
|
|
163
215
|
}
|
|
164
216
|
if (latestTurn?.status === "completed") {
|
|
@@ -169,7 +221,6 @@ export class RunReconciler {
|
|
|
169
221
|
thread,
|
|
170
222
|
threadId: run.threadId,
|
|
171
223
|
...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
|
|
172
|
-
resolveRecoverableRunState: resolveRecoverablePostRunState,
|
|
173
224
|
});
|
|
174
225
|
return;
|
|
175
226
|
}
|
|
@@ -198,21 +249,31 @@ export class RunReconciler {
|
|
|
198
249
|
return true;
|
|
199
250
|
}
|
|
200
251
|
releaseMergedRun(run, issue, reason) {
|
|
252
|
+
const mergedUpdate = {
|
|
253
|
+
projectId: run.projectId,
|
|
254
|
+
linearIssueId: run.linearIssueId,
|
|
255
|
+
activeRunId: null,
|
|
256
|
+
factoryState: "done",
|
|
257
|
+
prState: "merged",
|
|
258
|
+
pendingRunType: null,
|
|
259
|
+
pendingRunContextJson: null,
|
|
260
|
+
};
|
|
201
261
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
262
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
263
|
+
writer: WRITER,
|
|
264
|
+
expectedVersion: issue.version,
|
|
265
|
+
// The merge itself is external truth; only re-check that the run
|
|
266
|
+
// slot still belongs to this run before clearing it.
|
|
267
|
+
update: mergedUpdate,
|
|
268
|
+
onConflict: (current) => (current.activeRunId === run.id ? mergedUpdate : undefined),
|
|
269
|
+
});
|
|
270
|
+
if (commit.outcome !== "applied")
|
|
271
|
+
return;
|
|
202
272
|
this.db.issueSessions.clearPendingIssueSessionEvents(run.projectId, run.linearIssueId);
|
|
203
273
|
this.db.runs.finishRun(run.id, {
|
|
204
274
|
status: "released",
|
|
205
275
|
failureReason: reason,
|
|
206
276
|
});
|
|
207
|
-
this.db.issues.upsertIssue({
|
|
208
|
-
projectId: run.projectId,
|
|
209
|
-
linearIssueId: run.linearIssueId,
|
|
210
|
-
activeRunId: null,
|
|
211
|
-
factoryState: "done",
|
|
212
|
-
prState: "merged",
|
|
213
|
-
pendingRunType: null,
|
|
214
|
-
pendingRunContextJson: null,
|
|
215
|
-
});
|
|
216
277
|
});
|
|
217
278
|
this.feed?.publish({
|
|
218
279
|
level: "info",
|
|
@@ -287,19 +348,26 @@ export class RunReconciler {
|
|
|
287
348
|
},
|
|
288
349
|
});
|
|
289
350
|
if (delegated) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
351
|
+
// Live Linear is the authority on delegation; commit unconditionally.
|
|
352
|
+
const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
353
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
354
|
+
writer: WRITER,
|
|
355
|
+
update: {
|
|
356
|
+
projectId: run.projectId,
|
|
357
|
+
linearIssueId: run.linearIssueId,
|
|
358
|
+
delegatedToPatchRelay: true,
|
|
359
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
360
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
361
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
362
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
363
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
364
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
365
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
366
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
return commit.outcome === "applied" ? commit.issue : undefined;
|
|
370
|
+
}) ?? issue;
|
|
303
371
|
return { issue: repairedIssue, released: false };
|
|
304
372
|
}
|
|
305
373
|
appendRunReleasedAuthorityEvent(this.db, {
|
|
@@ -315,22 +383,27 @@ export class RunReconciler {
|
|
|
315
383
|
},
|
|
316
384
|
});
|
|
317
385
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
386
|
+
// Undelegation confirmed against live Linear — external truth, commit
|
|
387
|
+
// unconditionally; the run release rides in the same transaction.
|
|
388
|
+
this.db.issueSessions.commitIssueState({
|
|
389
|
+
writer: WRITER,
|
|
390
|
+
update: {
|
|
391
|
+
projectId: run.projectId,
|
|
392
|
+
linearIssueId: run.linearIssueId,
|
|
393
|
+
activeRunId: null,
|
|
394
|
+
factoryState: issue.factoryState,
|
|
395
|
+
delegatedToPatchRelay: false,
|
|
396
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
397
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
398
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
399
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
400
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
401
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
402
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
403
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
404
|
+
},
|
|
333
405
|
});
|
|
406
|
+
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
|
|
334
407
|
});
|
|
335
408
|
return { issue, released: true };
|
|
336
409
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const WRITER = "run-settlement";
|
|
2
|
+
const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "released", "superseded"]);
|
|
3
|
+
export function isTerminalRunStatus(status) {
|
|
4
|
+
return TERMINAL_RUN_STATUSES.has(status);
|
|
5
|
+
}
|
|
6
|
+
// Phase B1 (core simplification plan): the fast, transactional, idempotent
|
|
7
|
+
// half of run finalization. One transaction marks the run terminal and
|
|
8
|
+
// clears the issue's active slot — the two writes whose separation caused
|
|
9
|
+
// the dangling-active-run freeze (PR #566): a restart landing between them
|
|
10
|
+
// left `activeRunId` pointing at a terminal run forever, hiding the issue
|
|
11
|
+
// from every idle/recovery pass. Safe to call from both the notification
|
|
12
|
+
// finalizer and reconciliation at any time:
|
|
13
|
+
// - already-terminal run → finishRun skipped;
|
|
14
|
+
// - slot already cleared or re-pointed at another run → issue untouched;
|
|
15
|
+
// - non-terminal run with no `finish` outcome → full no-op.
|
|
16
|
+
export function settleRun(params) {
|
|
17
|
+
const { db, run } = params;
|
|
18
|
+
return db.transaction(() => {
|
|
19
|
+
const freshRun = db.runs.getRunById(run.id);
|
|
20
|
+
if (!freshRun) {
|
|
21
|
+
return { runFinished: false, slotCleared: false, issue: db.issues.getIssue(run.projectId, run.linearIssueId) };
|
|
22
|
+
}
|
|
23
|
+
let runFinished = false;
|
|
24
|
+
if (!isTerminalRunStatus(freshRun.status)) {
|
|
25
|
+
if (!params.finish) {
|
|
26
|
+
return { runFinished: false, slotCleared: false, issue: db.issues.getIssue(run.projectId, run.linearIssueId) };
|
|
27
|
+
}
|
|
28
|
+
db.runs.finishRun(run.id, params.finish);
|
|
29
|
+
runFinished = true;
|
|
30
|
+
}
|
|
31
|
+
const current = db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
32
|
+
if (!current || current.activeRunId !== run.id) {
|
|
33
|
+
return { runFinished, slotCleared: false, issue: current };
|
|
34
|
+
}
|
|
35
|
+
const buildUpdate = (record) => ({
|
|
36
|
+
projectId: run.projectId,
|
|
37
|
+
linearIssueId: run.linearIssueId,
|
|
38
|
+
...params.buildIssueUpdate?.(record),
|
|
39
|
+
// After the caller-provided fields so nothing can override the clear.
|
|
40
|
+
activeRunId: null,
|
|
41
|
+
});
|
|
42
|
+
const commit = db.issueSessions.commitIssueState({
|
|
43
|
+
writer: WRITER,
|
|
44
|
+
...(params.lease ? { lease: params.lease } : {}),
|
|
45
|
+
expectedVersion: current.version,
|
|
46
|
+
update: buildUpdate(current),
|
|
47
|
+
// The read above happened inside this same transaction, so a version
|
|
48
|
+
// conflict cannot normally occur; the predicate keeps the invariant
|
|
49
|
+
// explicit: never clear a slot that was re-pointed at another run.
|
|
50
|
+
onConflict: (fresh) => (fresh.activeRunId === run.id ? buildUpdate(fresh) : undefined),
|
|
51
|
+
});
|
|
52
|
+
if (commit.outcome !== "applied") {
|
|
53
|
+
return { runFinished, slotCleared: false, issue: commit.outcome === "conflict_skipped" ? commit.issue : current };
|
|
54
|
+
}
|
|
55
|
+
return { runFinished, slotCleared: true, issue: commit.issue };
|
|
56
|
+
});
|
|
57
|
+
}
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
|
|
2
2
|
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
3
|
+
const WRITER = "run-wake-planner";
|
|
3
4
|
export class RunWakePlanner {
|
|
4
5
|
db;
|
|
5
6
|
constructor(db) {
|
|
@@ -62,15 +63,19 @@ export class RunWakePlanner {
|
|
|
62
63
|
? JSON.parse(issue.pendingRunContextJson)
|
|
63
64
|
: undefined;
|
|
64
65
|
this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
67
|
+
writer: WRITER,
|
|
68
|
+
lease,
|
|
69
|
+
update: {
|
|
70
|
+
projectId: issue.projectId,
|
|
71
|
+
linearIssueId: issue.linearIssueId,
|
|
72
|
+
pendingRunType: null,
|
|
73
|
+
pendingRunContextJson: null,
|
|
74
|
+
},
|
|
70
75
|
});
|
|
71
|
-
if (
|
|
76
|
+
if (commit.outcome !== "applied")
|
|
72
77
|
return issue;
|
|
73
|
-
return
|
|
78
|
+
return commit.issue;
|
|
74
79
|
}
|
|
75
80
|
budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
|
|
76
81
|
const ciRepairBudget = getCiRepairBudget(project);
|
|
@@ -88,27 +93,32 @@ export class RunWakePlanner {
|
|
|
88
93
|
return undefined;
|
|
89
94
|
}
|
|
90
95
|
incrementAttemptCounters(issue, lease, runType, isRequestedChangesRunType) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
projectId: issue.projectId,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
// The increments are read-modify-write against the issue row (which may
|
|
97
|
+
// be stale by the time the launch path gets here); on conflict, recompute
|
|
98
|
+
// from the fresh row instead of writing a counter derived from the stale
|
|
99
|
+
// read.
|
|
100
|
+
const buildIncrement = (record) => {
|
|
101
|
+
if (runType === "ci_repair") {
|
|
102
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: record.ciRepairAttempts + 1 };
|
|
103
|
+
}
|
|
104
|
+
if (runType === "queue_repair") {
|
|
105
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: record.queueRepairAttempts + 1 };
|
|
106
|
+
}
|
|
107
|
+
if (isRequestedChangesRunType(runType)) {
|
|
108
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: record.reviewFixAttempts + 1 };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
112
|
+
const update = buildIncrement(issue);
|
|
113
|
+
if (!update)
|
|
114
|
+
return true;
|
|
115
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
116
|
+
writer: WRITER,
|
|
117
|
+
lease,
|
|
118
|
+
expectedVersion: issue.version,
|
|
119
|
+
update,
|
|
120
|
+
onConflict: (current) => buildIncrement(current),
|
|
121
|
+
});
|
|
122
|
+
return commit.outcome === "applied";
|
|
113
123
|
}
|
|
114
124
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildOperatorRetryEvent } from "./operator-retry-event.js";
|
|
2
2
|
import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
|
|
3
|
+
const WRITER = "service-issue-actions";
|
|
3
4
|
export class ServiceIssueActions {
|
|
4
5
|
config;
|
|
5
6
|
db;
|
|
@@ -76,10 +77,13 @@ export class ServiceIssueActions {
|
|
|
76
77
|
dedupeKey: `operator_stop:${issue.linearIssueId}`,
|
|
77
78
|
});
|
|
78
79
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
79
|
-
this.db.issueSessions.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
this.db.issueSessions.commitIssueState({
|
|
81
|
+
writer: WRITER,
|
|
82
|
+
update: {
|
|
83
|
+
projectId: issue.projectId,
|
|
84
|
+
linearIssueId: issue.linearIssueId,
|
|
85
|
+
factoryState: "awaiting_input",
|
|
86
|
+
},
|
|
83
87
|
});
|
|
84
88
|
this.feed.publish({
|
|
85
89
|
level: "warn",
|
|
@@ -111,19 +115,25 @@ export class ServiceIssueActions {
|
|
|
111
115
|
lastGitHubFailureSource: issue.lastGitHubFailureSource,
|
|
112
116
|
});
|
|
113
117
|
if (retryTarget.runType === "none") {
|
|
114
|
-
this.db.issueSessions.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
this.db.issueSessions.commitIssueState({
|
|
119
|
+
writer: WRITER,
|
|
120
|
+
update: {
|
|
121
|
+
projectId: issue.projectId,
|
|
122
|
+
linearIssueId: issue.linearIssueId,
|
|
123
|
+
factoryState: "done",
|
|
124
|
+
},
|
|
118
125
|
});
|
|
119
126
|
return { issueKey, runType: "none" };
|
|
120
127
|
}
|
|
121
128
|
this.appendOperatorRetryEvent(issue, retryTarget.runType);
|
|
122
|
-
this.db.issueSessions.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
this.db.issueSessions.commitIssueState({
|
|
130
|
+
writer: WRITER,
|
|
131
|
+
update: {
|
|
132
|
+
projectId: issue.projectId,
|
|
133
|
+
linearIssueId: issue.linearIssueId,
|
|
134
|
+
factoryState: retryTarget.factoryState,
|
|
135
|
+
...buildManualRetryAttemptReset(retryTarget.runType),
|
|
136
|
+
},
|
|
127
137
|
});
|
|
128
138
|
this.feed.publish({
|
|
129
139
|
level: "info",
|
|
@@ -168,22 +178,29 @@ export class ServiceIssueActions {
|
|
|
168
178
|
dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
|
|
169
179
|
});
|
|
170
180
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
// Operator close is authoritative: the issue terminal write and the run
|
|
182
|
+
// release ride in one transaction, with the run gated on the issue commit.
|
|
183
|
+
this.db.transaction(() => {
|
|
184
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
185
|
+
writer: WRITER,
|
|
186
|
+
update: {
|
|
187
|
+
projectId: issue.projectId,
|
|
188
|
+
linearIssueId: issue.linearIssueId,
|
|
189
|
+
delegatedToPatchRelay: false,
|
|
190
|
+
factoryState: terminalState,
|
|
191
|
+
activeRunId: null,
|
|
192
|
+
pendingRunType: null,
|
|
193
|
+
pendingRunContextJson: null,
|
|
194
|
+
},
|
|
177
195
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
pendingRunContextJson: null,
|
|
196
|
+
if (run && commit.outcome === "applied") {
|
|
197
|
+
this.db.runs.finishRun(run.id, {
|
|
198
|
+
status: "released",
|
|
199
|
+
failureReason: options?.reason
|
|
200
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
201
|
+
: `Operator closed issue as ${terminalState}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
187
204
|
});
|
|
188
205
|
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
189
206
|
this.feed.publish({
|