patchrelay 0.76.0 → 0.78.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/build-info.json +3 -3
- package/dist/db/webhook-event-store.js +22 -0
- package/dist/failure-provenance.js +40 -0
- package/dist/github-webhook-late-publication-guard.js +49 -15
- package/dist/github-webhook-policy.js +36 -25
- package/dist/github-webhook-sequence-backstop.js +45 -2
- package/dist/github-webhook-state-projector.js +5 -12
- package/dist/idle-reconciliation.js +63 -38
- package/dist/pr-facts-derivation.js +81 -0
- 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 +23 -22
- package/dist/run-launcher.js +21 -0
- package/dist/run-notification-handler.js +0 -2
- package/dist/run-orchestrator.js +26 -68
- package/dist/run-reconciler.js +34 -32
- package/dist/run-settlement.js +57 -0
- package/dist/service.js +22 -0
- package/package.json +1 -1
- package/dist/interrupted-run-recovery.js +0 -240
- package/dist/run-recovery-service.js +0 -239
- package/dist/zombie-recovery.js +0 -13
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
2
|
-
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
|
-
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
4
|
-
import { isRequestedChangesRunType } from "./reactive-pr-state.js";
|
|
5
|
-
const WRITER = "interrupted-run-recovery";
|
|
6
|
-
// Roll back the attempt counter consumed by the interrupted run and clear the
|
|
7
|
-
// attempted-failure provenance for repair runs, as a single issue update so
|
|
8
|
-
// the whole repair commits (and conflict-recomputes) atomically.
|
|
9
|
-
function buildInterruptedAttemptRepairUpdate(runType, issue) {
|
|
10
|
-
const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0
|
|
11
|
-
? { ciRepairAttempts: issue.ciRepairAttempts - 1 }
|
|
12
|
-
: runType === "queue_repair" && issue.queueRepairAttempts > 0
|
|
13
|
-
? { queueRepairAttempts: issue.queueRepairAttempts - 1 }
|
|
14
|
-
: isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0
|
|
15
|
-
? { reviewFixAttempts: issue.reviewFixAttempts - 1 }
|
|
16
|
-
: undefined;
|
|
17
|
-
const provenance = runType === "ci_repair" || runType === "queue_repair"
|
|
18
|
-
? {
|
|
19
|
-
lastAttemptedFailureHeadSha: null,
|
|
20
|
-
lastAttemptedFailureSignature: null,
|
|
21
|
-
lastAttemptedFailureAt: null,
|
|
22
|
-
}
|
|
23
|
-
: undefined;
|
|
24
|
-
if (!counter && !provenance)
|
|
25
|
-
return undefined;
|
|
26
|
-
return {
|
|
27
|
-
projectId: issue.projectId,
|
|
28
|
-
linearIssueId: issue.linearIssueId,
|
|
29
|
-
...counter,
|
|
30
|
-
...provenance,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
function resolveRetryRunType(runType, context) {
|
|
34
|
-
if (runType === "branch_upkeep") {
|
|
35
|
-
return "branch_upkeep";
|
|
36
|
-
}
|
|
37
|
-
return context?.reviewFixMode === "branch_upkeep" || context?.branchUpkeepRequired === true
|
|
38
|
-
? "branch_upkeep"
|
|
39
|
-
: "review_fix";
|
|
40
|
-
}
|
|
41
|
-
function resolvePostRunState(issue) {
|
|
42
|
-
if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
|
|
43
|
-
if (issue.prState === "merged")
|
|
44
|
-
return "done";
|
|
45
|
-
if (issue.prReviewState === "approved")
|
|
46
|
-
return "awaiting_queue";
|
|
47
|
-
return "pr_open";
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
export function resolveRecoverablePostRunState(issue) {
|
|
52
|
-
if (!issue.prNumber) {
|
|
53
|
-
return resolvePostRunState(issue);
|
|
54
|
-
}
|
|
55
|
-
if (issue.prState === "merged")
|
|
56
|
-
return "done";
|
|
57
|
-
if (issue.prState === "open") {
|
|
58
|
-
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
59
|
-
prNumber: issue.prNumber,
|
|
60
|
-
prState: issue.prState,
|
|
61
|
-
prReviewState: issue.prReviewState,
|
|
62
|
-
prCheckStatus: issue.prCheckStatus,
|
|
63
|
-
latestFailureSource: issue.lastGitHubFailureSource,
|
|
64
|
-
});
|
|
65
|
-
if (reactiveIntent)
|
|
66
|
-
return reactiveIntent.compatibilityFactoryState;
|
|
67
|
-
if (issue.prReviewState === "approved")
|
|
68
|
-
return "awaiting_queue";
|
|
69
|
-
return "pr_open";
|
|
70
|
-
}
|
|
71
|
-
return resolvePostRunState(issue);
|
|
72
|
-
}
|
|
73
|
-
export class InterruptedRunRecovery {
|
|
74
|
-
db;
|
|
75
|
-
logger;
|
|
76
|
-
linearSync;
|
|
77
|
-
withHeldLease;
|
|
78
|
-
releaseLease;
|
|
79
|
-
failRunAndClear;
|
|
80
|
-
restoreIdleWorktree;
|
|
81
|
-
completionPolicy;
|
|
82
|
-
enqueueIssue;
|
|
83
|
-
feed;
|
|
84
|
-
constructor(db, logger, linearSync, withHeldLease, releaseLease, failRunAndClear, restoreIdleWorktree, completionPolicy, enqueueIssue, feed) {
|
|
85
|
-
this.db = db;
|
|
86
|
-
this.logger = logger;
|
|
87
|
-
this.linearSync = linearSync;
|
|
88
|
-
this.withHeldLease = withHeldLease;
|
|
89
|
-
this.releaseLease = releaseLease;
|
|
90
|
-
this.failRunAndClear = failRunAndClear;
|
|
91
|
-
this.restoreIdleWorktree = restoreIdleWorktree;
|
|
92
|
-
this.completionPolicy = completionPolicy;
|
|
93
|
-
this.enqueueIssue = enqueueIssue;
|
|
94
|
-
this.feed = feed;
|
|
95
|
-
}
|
|
96
|
-
async handle(run, issue) {
|
|
97
|
-
this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn - marking as failed");
|
|
98
|
-
const repairedCounters = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
99
|
-
// The decrement is read-modify-write against an issue row read before
|
|
100
|
-
// the awaits that led here; on conflict, recompute from the fresh row.
|
|
101
|
-
const update = buildInterruptedAttemptRepairUpdate(run.runType, issue);
|
|
102
|
-
if (update) {
|
|
103
|
-
this.db.issueSessions.commitIssueState({
|
|
104
|
-
writer: WRITER,
|
|
105
|
-
lease,
|
|
106
|
-
expectedVersion: issue.version,
|
|
107
|
-
update,
|
|
108
|
-
onConflict: (current) => buildInterruptedAttemptRepairUpdate(run.runType, current),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
return true;
|
|
112
|
-
});
|
|
113
|
-
if (!repairedCounters) {
|
|
114
|
-
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping interrupted-run recovery after losing issue-session lease");
|
|
115
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (isRequestedChangesRunType(run.runType)) {
|
|
119
|
-
await this.handleInterruptedRequestedChangesRun(run, issue);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (run.runType === "implementation" && !issue.prNumber) {
|
|
123
|
-
await this.handleInterruptedImplementationRun(run, issue);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const recoveredState = resolveRecoverablePostRunState(this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue);
|
|
127
|
-
this.failRunAndClear(run, "Codex turn was interrupted", recoveredState);
|
|
128
|
-
await this.restoreIdleWorktree(issue);
|
|
129
|
-
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
130
|
-
if (recoveredState) {
|
|
131
|
-
this.feed?.publish({
|
|
132
|
-
level: "info",
|
|
133
|
-
kind: "stage",
|
|
134
|
-
issueKey: issue.issueKey,
|
|
135
|
-
projectId: run.projectId,
|
|
136
|
-
stage: recoveredState,
|
|
137
|
-
status: "reconciled",
|
|
138
|
-
summary: `Interrupted ${run.runType} recovered -> ${recoveredState}`,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
|
|
143
|
-
}
|
|
144
|
-
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
145
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
146
|
-
}
|
|
147
|
-
async handleInterruptedImplementationRun(run, issue) {
|
|
148
|
-
const interruptedMessage = "Implementation run was interrupted before PatchRelay could publish a PR";
|
|
149
|
-
this.failRunAndClear(run, "Codex turn was interrupted", "delegated");
|
|
150
|
-
await this.restoreIdleWorktree(issue);
|
|
151
|
-
const refreshedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
152
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(run.projectId, run.linearIssueId, {
|
|
153
|
-
projectId: run.projectId,
|
|
154
|
-
linearIssueId: run.linearIssueId,
|
|
155
|
-
eventType: "delegated",
|
|
156
|
-
dedupeKey: `interrupted_implementation:implementation:${run.linearIssueId}`,
|
|
157
|
-
});
|
|
158
|
-
if (!this.db.workflowWakes.peekIssueWake(run.projectId, run.linearIssueId)) {
|
|
159
|
-
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
160
|
-
this.feed?.publish({
|
|
161
|
-
level: "error",
|
|
162
|
-
kind: "workflow",
|
|
163
|
-
issueKey: issue.issueKey,
|
|
164
|
-
projectId: run.projectId,
|
|
165
|
-
stage: run.runType,
|
|
166
|
-
status: "escalated",
|
|
167
|
-
summary: interruptedMessage,
|
|
168
|
-
});
|
|
169
|
-
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, interruptedMessage));
|
|
170
|
-
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
171
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
this.feed?.publish({
|
|
175
|
-
level: "warn",
|
|
176
|
-
kind: "workflow",
|
|
177
|
-
issueKey: issue.issueKey,
|
|
178
|
-
projectId: run.projectId,
|
|
179
|
-
stage: run.runType,
|
|
180
|
-
status: "retry_queued",
|
|
181
|
-
summary: "Implementation run was interrupted; PatchRelay will retry automatically",
|
|
182
|
-
});
|
|
183
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
184
|
-
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
185
|
-
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
186
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
187
|
-
}
|
|
188
|
-
async handleInterruptedRequestedChangesRun(run, issue) {
|
|
189
|
-
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
190
|
-
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
|
191
|
-
const retryContext = await this.completionPolicy.resolveRequestedChangesWakeContext(refreshedIssue, run.runType, run.runType === "branch_upkeep"
|
|
192
|
-
? {
|
|
193
|
-
branchUpkeepRequired: true,
|
|
194
|
-
reviewFixMode: "branch_upkeep",
|
|
195
|
-
wakeReason: "branch_upkeep",
|
|
196
|
-
}
|
|
197
|
-
: undefined);
|
|
198
|
-
const retryRunType = resolveRetryRunType(run.runType, retryContext);
|
|
199
|
-
const recoveredState = resolveRecoverablePostRunState(refreshedIssue) ?? "failed";
|
|
200
|
-
const interruptedMessage = "Requested-changes run was interrupted before PatchRelay could verify that a new PR head was published";
|
|
201
|
-
this.failRunAndClear(run, interruptedMessage, recoveredState);
|
|
202
|
-
await this.restoreIdleWorktree(issue);
|
|
203
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
204
|
-
if (recoveredState === "changes_requested") {
|
|
205
|
-
this.db.issueSessions.commitIssueState({
|
|
206
|
-
writer: WRITER,
|
|
207
|
-
update: {
|
|
208
|
-
projectId: run.projectId,
|
|
209
|
-
linearIssueId: run.linearIssueId,
|
|
210
|
-
pendingRunType: retryRunType,
|
|
211
|
-
pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
this.feed?.publish({
|
|
215
|
-
level: "warn",
|
|
216
|
-
kind: "workflow",
|
|
217
|
-
issueKey: issue.issueKey,
|
|
218
|
-
projectId: run.projectId,
|
|
219
|
-
stage: run.runType,
|
|
220
|
-
status: "retry_queued",
|
|
221
|
-
summary: "Requested-changes run was interrupted; PatchRelay will retry from fresh GitHub truth",
|
|
222
|
-
});
|
|
223
|
-
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
this.feed?.publish({
|
|
227
|
-
level: "error",
|
|
228
|
-
kind: "workflow",
|
|
229
|
-
issueKey: issue.issueKey,
|
|
230
|
-
projectId: run.projectId,
|
|
231
|
-
stage: run.runType,
|
|
232
|
-
status: "escalated",
|
|
233
|
-
summary: interruptedMessage,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, interruptedMessage));
|
|
237
|
-
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
238
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
2
|
-
import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
|
|
3
|
-
const WRITER = "run-recovery-service";
|
|
4
|
-
const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
|
|
5
|
-
export class RunRecoveryService {
|
|
6
|
-
db;
|
|
7
|
-
logger;
|
|
8
|
-
linearSync;
|
|
9
|
-
withHeldLease;
|
|
10
|
-
getHeldLease;
|
|
11
|
-
appendWakeEventWithLease;
|
|
12
|
-
releaseLease;
|
|
13
|
-
enqueueIssue;
|
|
14
|
-
feed;
|
|
15
|
-
constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, feed) {
|
|
16
|
-
this.db = db;
|
|
17
|
-
this.logger = logger;
|
|
18
|
-
this.linearSync = linearSync;
|
|
19
|
-
this.withHeldLease = withHeldLease;
|
|
20
|
-
this.getHeldLease = getHeldLease;
|
|
21
|
-
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
22
|
-
this.releaseLease = releaseLease;
|
|
23
|
-
this.enqueueIssue = enqueueIssue;
|
|
24
|
-
this.feed = feed;
|
|
25
|
-
}
|
|
26
|
-
recoverOrEscalate(params) {
|
|
27
|
-
const { issue, runType, reason } = params;
|
|
28
|
-
const fresh = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
|
|
29
|
-
if (!fresh)
|
|
30
|
-
return;
|
|
31
|
-
if (params.isRequestedChangesRunType(runType)) {
|
|
32
|
-
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
33
|
-
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
34
|
-
this.db.issueSessions.commitIssueState({
|
|
35
|
-
writer: WRITER,
|
|
36
|
-
lease,
|
|
37
|
-
update: {
|
|
38
|
-
projectId: fresh.projectId,
|
|
39
|
-
linearIssueId: fresh.linearIssueId,
|
|
40
|
-
pendingRunType: null,
|
|
41
|
-
pendingRunContextJson: null,
|
|
42
|
-
factoryState: "escalated",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
return true;
|
|
46
|
-
});
|
|
47
|
-
if (!updated) {
|
|
48
|
-
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping review-fix recovery escalation after losing issue-session lease");
|
|
49
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Requested-changes run failed before a new head was published - escalating");
|
|
53
|
-
this.feed?.publish({
|
|
54
|
-
level: "error",
|
|
55
|
-
kind: "workflow",
|
|
56
|
-
issueKey: fresh.issueKey,
|
|
57
|
-
projectId: fresh.projectId,
|
|
58
|
-
stage: runType,
|
|
59
|
-
status: "escalated",
|
|
60
|
-
summary: `Requested-changes run failed before publishing a new head (${reason})`,
|
|
61
|
-
});
|
|
62
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (fresh.prState === "merged") {
|
|
66
|
-
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
67
|
-
this.db.issueSessions.commitIssueState({
|
|
68
|
-
writer: WRITER,
|
|
69
|
-
lease,
|
|
70
|
-
update: {
|
|
71
|
-
projectId: fresh.projectId,
|
|
72
|
-
linearIssueId: fresh.linearIssueId,
|
|
73
|
-
factoryState: "done",
|
|
74
|
-
zombieRecoveryAttempts: 0,
|
|
75
|
-
lastZombieRecoveryAt: null,
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
return true;
|
|
79
|
-
});
|
|
80
|
-
if (!updated) {
|
|
81
|
-
this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping merged recovery completion after losing issue-session lease");
|
|
82
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
this.logger.info({ issueKey: fresh.issueKey, reason }, "Recovery: PR already merged - transitioning to done");
|
|
86
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const attempts = fresh.zombieRecoveryAttempts + 1;
|
|
90
|
-
if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
|
|
91
|
-
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
92
|
-
this.db.issueSessions.commitIssueState({
|
|
93
|
-
writer: WRITER,
|
|
94
|
-
lease,
|
|
95
|
-
update: {
|
|
96
|
-
projectId: fresh.projectId,
|
|
97
|
-
linearIssueId: fresh.linearIssueId,
|
|
98
|
-
factoryState: "escalated",
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
return true;
|
|
102
|
-
});
|
|
103
|
-
if (!updated) {
|
|
104
|
-
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery escalation after losing issue-session lease");
|
|
105
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: budget exhausted - escalating");
|
|
109
|
-
this.feed?.publish({
|
|
110
|
-
level: "error",
|
|
111
|
-
kind: "workflow",
|
|
112
|
-
issueKey: fresh.issueKey,
|
|
113
|
-
projectId: fresh.projectId,
|
|
114
|
-
stage: "escalated",
|
|
115
|
-
status: "budget_exhausted",
|
|
116
|
-
summary: `${reason} recovery failed after ${DEFAULT_ZOMBIE_RECOVERY_BUDGET} attempts`,
|
|
117
|
-
});
|
|
118
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (fresh.lastZombieRecoveryAt) {
|
|
122
|
-
const remainingDelayMs = getRemainingZombieRecoveryDelayMs(fresh.lastZombieRecoveryAt, fresh.zombieRecoveryAttempts);
|
|
123
|
-
if (remainingDelayMs > 0) {
|
|
124
|
-
this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
125
|
-
this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
|
|
126
|
-
});
|
|
127
|
-
this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, remainingDelayMs }, "Recovery: backoff not elapsed, deferring retry");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const requeued = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
132
|
-
// `attempts` is read-modify-write against the fresh row read above; on
|
|
133
|
-
// conflict recompute the counter from the current row.
|
|
134
|
-
const buildRequeueUpdate = (record) => ({
|
|
135
|
-
projectId: fresh.projectId,
|
|
136
|
-
linearIssueId: fresh.linearIssueId,
|
|
137
|
-
pendingRunType: null,
|
|
138
|
-
pendingRunContextJson: null,
|
|
139
|
-
zombieRecoveryAttempts: record.zombieRecoveryAttempts + 1,
|
|
140
|
-
lastZombieRecoveryAt: new Date().toISOString(),
|
|
141
|
-
});
|
|
142
|
-
this.db.issueSessions.commitIssueState({
|
|
143
|
-
writer: WRITER,
|
|
144
|
-
lease,
|
|
145
|
-
expectedVersion: fresh.version,
|
|
146
|
-
update: buildRequeueUpdate(fresh),
|
|
147
|
-
onConflict: (current) => buildRequeueUpdate(current),
|
|
148
|
-
});
|
|
149
|
-
return this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
|
|
150
|
-
});
|
|
151
|
-
if (!requeued) {
|
|
152
|
-
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
|
|
153
|
-
this.releaseLease(fresh.projectId, fresh.linearIssueId);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
this.enqueueIssue(fresh.projectId, fresh.linearIssueId);
|
|
157
|
-
this.logger.info({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: re-enqueued with backoff");
|
|
158
|
-
}
|
|
159
|
-
escalate(params) {
|
|
160
|
-
const { issue, runType, reason } = params;
|
|
161
|
-
this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
|
|
162
|
-
const escalated = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
163
|
-
// Escalation is an operator-facing decision: the issue write and the
|
|
164
|
-
// run release ride in the held-lease transaction, with the run gated
|
|
165
|
-
// on the issue commit.
|
|
166
|
-
const commit = this.db.issueSessions.commitIssueState({
|
|
167
|
-
writer: WRITER,
|
|
168
|
-
lease,
|
|
169
|
-
update: {
|
|
170
|
-
projectId: issue.projectId,
|
|
171
|
-
linearIssueId: issue.linearIssueId,
|
|
172
|
-
pendingRunType: null,
|
|
173
|
-
pendingRunContextJson: null,
|
|
174
|
-
activeRunId: null,
|
|
175
|
-
factoryState: "escalated",
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
if (commit.outcome !== "applied")
|
|
179
|
-
return false;
|
|
180
|
-
if (issue.activeRunId) {
|
|
181
|
-
this.db.runs.finishRun(issue.activeRunId, { status: "released" });
|
|
182
|
-
}
|
|
183
|
-
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
184
|
-
return true;
|
|
185
|
-
});
|
|
186
|
-
if (!escalated) {
|
|
187
|
-
this.logger.warn({ issueKey: issue.issueKey, runType }, "Skipping escalation write after losing issue-session lease");
|
|
188
|
-
this.releaseLease(issue.projectId, issue.linearIssueId);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
this.feed?.publish({
|
|
192
|
-
level: "error",
|
|
193
|
-
kind: "workflow",
|
|
194
|
-
issueKey: issue.issueKey,
|
|
195
|
-
projectId: issue.projectId,
|
|
196
|
-
stage: runType,
|
|
197
|
-
status: "escalated",
|
|
198
|
-
summary: `Escalated: ${reason}`,
|
|
199
|
-
});
|
|
200
|
-
const escalatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
201
|
-
void this.linearSync.emitActivity(escalatedIssue, {
|
|
202
|
-
type: "error",
|
|
203
|
-
body: `PatchRelay needs human help to continue.\n\n${reason}`,
|
|
204
|
-
});
|
|
205
|
-
void this.linearSync.syncSession(escalatedIssue);
|
|
206
|
-
this.releaseLease(issue.projectId, issue.linearIssueId);
|
|
207
|
-
}
|
|
208
|
-
failRunAndClear(params) {
|
|
209
|
-
const { run, message, nextState } = params;
|
|
210
|
-
const updated = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
211
|
-
const commit = this.db.issueSessions.commitIssueState({
|
|
212
|
-
writer: WRITER,
|
|
213
|
-
update: {
|
|
214
|
-
projectId: run.projectId,
|
|
215
|
-
linearIssueId: run.linearIssueId,
|
|
216
|
-
activeRunId: null,
|
|
217
|
-
factoryState: nextState,
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
if (commit.outcome !== "applied") {
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
this.db.runs.finishRun(run.id, { status: "failed", failureReason: message });
|
|
224
|
-
if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
|
|
225
|
-
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
226
|
-
}
|
|
227
|
-
return true;
|
|
228
|
-
});
|
|
229
|
-
if (!updated) {
|
|
230
|
-
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failure cleanup after losing issue-session lease");
|
|
231
|
-
}
|
|
232
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
233
|
-
}
|
|
234
|
-
emitInterruptedFailure(runType, issue, message) {
|
|
235
|
-
const latest = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
236
|
-
void this.linearSync.emitActivity(latest, buildRunFailureActivity(runType, message));
|
|
237
|
-
void this.linearSync.syncSession(latest, { activeRunType: runType });
|
|
238
|
-
}
|
|
239
|
-
}
|
package/dist/zombie-recovery.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
|
|
2
|
-
export function getZombieRecoveryDelayMs(recoveryAttempts) {
|
|
3
|
-
return ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, recoveryAttempts);
|
|
4
|
-
}
|
|
5
|
-
export function getRemainingZombieRecoveryDelayMs(lastRecoveryAt, recoveryAttempts, now = Date.now()) {
|
|
6
|
-
if (!lastRecoveryAt)
|
|
7
|
-
return 0;
|
|
8
|
-
const recoveredAtMs = Date.parse(lastRecoveryAt);
|
|
9
|
-
if (!Number.isFinite(recoveredAtMs))
|
|
10
|
-
return 0;
|
|
11
|
-
const delay = getZombieRecoveryDelayMs(recoveryAttempts);
|
|
12
|
-
return Math.max(0, recoveredAtMs + delay - now);
|
|
13
|
-
}
|