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
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
2
|
+
import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
|
|
2
3
|
const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
|
|
3
|
-
const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000;
|
|
4
4
|
export class RunRecoveryService {
|
|
5
5
|
db;
|
|
6
6
|
logger;
|
|
7
7
|
linearSync;
|
|
8
|
+
withHeldLease;
|
|
9
|
+
getHeldLease;
|
|
10
|
+
appendWakeEventWithLease;
|
|
8
11
|
releaseLease;
|
|
9
12
|
enqueueIssue;
|
|
10
13
|
resolveBranchOwnerForStateTransition;
|
|
11
14
|
feed;
|
|
12
|
-
constructor(db, logger, linearSync, releaseLease, enqueueIssue, resolveBranchOwnerForStateTransition, feed) {
|
|
15
|
+
constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, resolveBranchOwnerForStateTransition, feed) {
|
|
13
16
|
this.db = db;
|
|
14
17
|
this.logger = logger;
|
|
15
18
|
this.linearSync = linearSync;
|
|
19
|
+
this.withHeldLease = withHeldLease;
|
|
20
|
+
this.getHeldLease = getHeldLease;
|
|
21
|
+
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
16
22
|
this.releaseLease = releaseLease;
|
|
17
23
|
this.enqueueIssue = enqueueIssue;
|
|
18
24
|
this.resolveBranchOwnerForStateTransition = resolveBranchOwnerForStateTransition;
|
|
@@ -20,11 +26,11 @@ export class RunRecoveryService {
|
|
|
20
26
|
}
|
|
21
27
|
recoverOrEscalate(params) {
|
|
22
28
|
const { issue, runType, reason } = params;
|
|
23
|
-
const fresh = this.db.getIssue(issue.projectId, issue.linearIssueId);
|
|
29
|
+
const fresh = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
|
|
24
30
|
if (!fresh)
|
|
25
31
|
return;
|
|
26
32
|
if (params.isRequestedChangesRunType(runType)) {
|
|
27
|
-
const updated =
|
|
33
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
28
34
|
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
29
35
|
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
30
36
|
projectId: fresh.projectId,
|
|
@@ -54,7 +60,7 @@ export class RunRecoveryService {
|
|
|
54
60
|
return;
|
|
55
61
|
}
|
|
56
62
|
if (fresh.prState === "merged") {
|
|
57
|
-
const updated =
|
|
63
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
58
64
|
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
59
65
|
projectId: fresh.projectId,
|
|
60
66
|
linearIssueId: fresh.linearIssueId,
|
|
@@ -75,7 +81,7 @@ export class RunRecoveryService {
|
|
|
75
81
|
}
|
|
76
82
|
const attempts = fresh.zombieRecoveryAttempts + 1;
|
|
77
83
|
if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
|
|
78
|
-
const updated =
|
|
84
|
+
const updated = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
79
85
|
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
80
86
|
projectId: fresh.projectId,
|
|
81
87
|
linearIssueId: fresh.linearIssueId,
|
|
@@ -102,14 +108,16 @@ export class RunRecoveryService {
|
|
|
102
108
|
return;
|
|
103
109
|
}
|
|
104
110
|
if (fresh.lastZombieRecoveryAt) {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
const remainingDelayMs = getRemainingZombieRecoveryDelayMs(fresh.lastZombieRecoveryAt, fresh.zombieRecoveryAttempts);
|
|
112
|
+
if (remainingDelayMs > 0) {
|
|
113
|
+
this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
114
|
+
this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
|
|
115
|
+
});
|
|
116
|
+
this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, remainingDelayMs }, "Recovery: backoff not elapsed, deferring retry");
|
|
109
117
|
return;
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
|
-
const requeued =
|
|
120
|
+
const requeued = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
|
|
113
121
|
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
114
122
|
projectId: fresh.projectId,
|
|
115
123
|
linearIssueId: fresh.linearIssueId,
|
|
@@ -118,7 +126,7 @@ export class RunRecoveryService {
|
|
|
118
126
|
zombieRecoveryAttempts: attempts,
|
|
119
127
|
lastZombieRecoveryAt: new Date().toISOString(),
|
|
120
128
|
});
|
|
121
|
-
return
|
|
129
|
+
return this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
|
|
122
130
|
});
|
|
123
131
|
if (!requeued) {
|
|
124
132
|
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
|
|
@@ -131,7 +139,7 @@ export class RunRecoveryService {
|
|
|
131
139
|
escalate(params) {
|
|
132
140
|
const { issue, runType, reason } = params;
|
|
133
141
|
this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
|
|
134
|
-
const escalated =
|
|
142
|
+
const escalated = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
135
143
|
if (issue.activeRunId) {
|
|
136
144
|
this.db.issueSessions.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
|
|
137
145
|
}
|
|
@@ -160,7 +168,7 @@ export class RunRecoveryService {
|
|
|
160
168
|
status: "escalated",
|
|
161
169
|
summary: `Escalated: ${reason}`,
|
|
162
170
|
});
|
|
163
|
-
const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
171
|
+
const escalatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
164
172
|
void this.linearSync.emitActivity(escalatedIssue, {
|
|
165
173
|
type: "error",
|
|
166
174
|
body: `PatchRelay needs human help to continue.\n\n${reason}`,
|
|
@@ -170,12 +178,12 @@ export class RunRecoveryService {
|
|
|
170
178
|
}
|
|
171
179
|
failRunAndClear(params) {
|
|
172
180
|
const { run, message, nextState } = params;
|
|
173
|
-
const updated =
|
|
181
|
+
const updated = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
174
182
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: message });
|
|
175
183
|
if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
|
|
176
184
|
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
177
185
|
}
|
|
178
|
-
this.db.upsertIssue({
|
|
186
|
+
this.db.issues.upsertIssue({
|
|
179
187
|
projectId: run.projectId,
|
|
180
188
|
linearIssueId: run.linearIssueId,
|
|
181
189
|
activeRunId: null,
|
|
@@ -183,7 +191,7 @@ export class RunRecoveryService {
|
|
|
183
191
|
});
|
|
184
192
|
const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
|
|
185
193
|
if (branchOwner) {
|
|
186
|
-
const heldLease =
|
|
194
|
+
const heldLease = this.getHeldLease(run.projectId, run.linearIssueId);
|
|
187
195
|
if (heldLease) {
|
|
188
196
|
this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
|
|
189
197
|
}
|
|
@@ -196,7 +204,7 @@ export class RunRecoveryService {
|
|
|
196
204
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
197
205
|
}
|
|
198
206
|
emitInterruptedFailure(runType, issue, message) {
|
|
199
|
-
const latest = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
207
|
+
const latest = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
200
208
|
void this.linearSync.emitActivity(latest, buildRunFailureActivity(runType, message));
|
|
201
209
|
void this.linearSync.syncSession(latest, { activeRunType: runType });
|
|
202
210
|
}
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -60,7 +60,7 @@ export class RunWakePlanner {
|
|
|
60
60
|
});
|
|
61
61
|
if (!updated)
|
|
62
62
|
return issue;
|
|
63
|
-
return this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
63
|
+
return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
64
64
|
}
|
|
65
65
|
budgetExceeded(issue, runType, isRequestedChangesRunType) {
|
|
66
66
|
if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
|
package/dist/service.js
CHANGED
|
@@ -182,7 +182,7 @@ export class PatchRelayService {
|
|
|
182
182
|
await this.runtime.stop();
|
|
183
183
|
}
|
|
184
184
|
async syncKnownAgentSessions() {
|
|
185
|
-
for (const issue of this.db.listIssues()) {
|
|
185
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
186
186
|
if (issue.factoryState === "done") {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
@@ -191,7 +191,7 @@ export class PatchRelayService {
|
|
|
191
191
|
: (() => {
|
|
192
192
|
const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
|
|
193
193
|
return recoveredAgentSessionId
|
|
194
|
-
? this.db.upsertIssue({
|
|
194
|
+
? this.db.issues.upsertIssue({
|
|
195
195
|
projectId: issue.projectId,
|
|
196
196
|
linearIssueId: issue.linearIssueId,
|
|
197
197
|
agentSessionId: recoveredAgentSessionId,
|
|
@@ -206,7 +206,7 @@ export class PatchRelayService {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
async recoverDelegatedIssueStateFromLinear() {
|
|
209
|
-
for (const issue of this.db.listIssuesWithAgentSessions()) {
|
|
209
|
+
for (const issue of this.db.issues.listIssuesWithAgentSessions()) {
|
|
210
210
|
if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
|
|
211
211
|
continue;
|
|
212
212
|
}
|
|
@@ -222,7 +222,7 @@ export class PatchRelayService {
|
|
|
222
222
|
if (!liveIssue) {
|
|
223
223
|
continue;
|
|
224
224
|
}
|
|
225
|
-
this.db.replaceIssueDependencies({
|
|
225
|
+
this.db.issues.replaceIssueDependencies({
|
|
226
226
|
projectId: issue.projectId,
|
|
227
227
|
linearIssueId: issue.linearIssueId,
|
|
228
228
|
blockers: liveIssue.blockedBy.map((blocker) => ({
|
|
@@ -234,11 +234,11 @@ export class PatchRelayService {
|
|
|
234
234
|
})),
|
|
235
235
|
});
|
|
236
236
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
237
|
-
const unresolvedBlockers = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
237
|
+
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
238
238
|
const shouldRecoverAwaitingInput = delegated
|
|
239
239
|
&& issue.factoryState === "awaiting_input"
|
|
240
240
|
&& this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
|
|
241
|
-
const updated = this.db.upsertIssue({
|
|
241
|
+
const updated = this.db.issues.upsertIssue({
|
|
242
242
|
projectId: issue.projectId,
|
|
243
243
|
linearIssueId: issue.linearIssueId,
|
|
244
244
|
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
@@ -520,7 +520,7 @@ export class PatchRelayService {
|
|
|
520
520
|
});
|
|
521
521
|
}
|
|
522
522
|
async promptIssue(issueKey, text, source = "watch") {
|
|
523
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
523
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
524
524
|
if (!issue)
|
|
525
525
|
return undefined;
|
|
526
526
|
// Publish to operator feed so all clients see the prompt
|
|
@@ -572,7 +572,7 @@ export class PatchRelayService {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
async stopIssue(issueKey) {
|
|
575
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
575
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
576
576
|
if (!issue)
|
|
577
577
|
return undefined;
|
|
578
578
|
if (!issue.activeRunId)
|
|
@@ -613,7 +613,7 @@ export class PatchRelayService {
|
|
|
613
613
|
return { stopped: true };
|
|
614
614
|
}
|
|
615
615
|
retryIssue(issueKey) {
|
|
616
|
-
const issue = this.db.getIssueByKey(issueKey);
|
|
616
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
617
617
|
if (!issue)
|
|
618
618
|
return undefined;
|
|
619
619
|
if (issue.activeRunId)
|
package/dist/webhook-handler.js
CHANGED
|
@@ -190,17 +190,17 @@ export class WebhookHandler {
|
|
|
190
190
|
}
|
|
191
191
|
reconcileDependentReadiness(projectId, blockerLinearIssueId) {
|
|
192
192
|
const newlyReady = [];
|
|
193
|
-
for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
|
|
194
|
-
const issue = this.db.getIssue(projectId, dependent.linearIssueId);
|
|
193
|
+
for (const dependent of this.db.issues.listDependents(projectId, blockerLinearIssueId)) {
|
|
194
|
+
const issue = this.db.issues.getIssue(projectId, dependent.linearIssueId);
|
|
195
195
|
if (!issue) {
|
|
196
196
|
continue;
|
|
197
197
|
}
|
|
198
|
-
const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
198
|
+
const unresolved = this.db.issues.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
199
199
|
if (unresolved > 0) {
|
|
200
200
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
|
|
201
201
|
&& issue.activeRunId === undefined
|
|
202
202
|
&& !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
203
|
-
this.db.upsertIssue({
|
|
203
|
+
this.db.issues.upsertIssue({
|
|
204
204
|
projectId,
|
|
205
205
|
linearIssueId: dependent.linearIssueId,
|
|
206
206
|
pendingRunType: null,
|
|
@@ -213,7 +213,7 @@ export class WebhookHandler {
|
|
|
213
213
|
continue;
|
|
214
214
|
}
|
|
215
215
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
|
|
216
|
-
this.db.upsertIssue({
|
|
216
|
+
this.db.issues.upsertIssue({
|
|
217
217
|
projectId,
|
|
218
218
|
linearIssueId: dependent.linearIssueId,
|
|
219
219
|
pendingRunType: null,
|
|
@@ -24,24 +24,24 @@ export class AgentSessionHandler {
|
|
|
24
24
|
const linear = await this.linearProvider.forProject(project.id);
|
|
25
25
|
if (!linear)
|
|
26
26
|
return;
|
|
27
|
-
const existingIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
27
|
+
const existingIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
28
28
|
const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
|
|
29
29
|
if (normalized.triggerEvent === "agentSessionCreated") {
|
|
30
30
|
if (!delegated) {
|
|
31
|
-
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
31
|
+
const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
32
32
|
if (latestIssue ?? trackedIssue) {
|
|
33
33
|
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType);
|
|
34
34
|
}
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
if (wakeRunType) {
|
|
38
|
-
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
38
|
+
const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
39
39
|
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: wakeRunType });
|
|
40
40
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType));
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
if (activeRun) {
|
|
44
|
-
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
44
|
+
const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
45
45
|
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { activeRunType: activeRun.runType });
|
|
46
46
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
|
|
47
47
|
return;
|
|
@@ -115,13 +115,13 @@ export class AgentSessionHandler {
|
|
|
115
115
|
const queuedRunType = hadPendingWake
|
|
116
116
|
? params.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
|
|
117
117
|
: params.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
118
|
-
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
118
|
+
const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
119
119
|
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: queuedRunType ?? wakeRunType ?? (existingIssue.prReviewState === "changes_requested" ? "review_fix" : "implementation") });
|
|
120
120
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(queuedRunType ?? wakeRunType ?? "implementation"), { ephemeral: true });
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
if (wakeRunType) {
|
|
124
|
-
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
124
|
+
const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
125
125
|
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: wakeRunType });
|
|
126
126
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
|
|
127
127
|
}
|
|
@@ -165,7 +165,7 @@ export class AgentSessionHandler {
|
|
|
165
165
|
status: "stopped",
|
|
166
166
|
summary: "Stop signal received - work halted",
|
|
167
167
|
});
|
|
168
|
-
const updatedIssue = this.db.getIssue(params.project.id, issueId);
|
|
168
|
+
const updatedIssue = this.db.issues.getIssue(params.project.id, issueId);
|
|
169
169
|
await this.publishAgentActivity(params.linear, sessionId, buildStopConfirmationActivity());
|
|
170
170
|
await params.syncAgentSession(sessionId, updatedIssue ?? params.trackedIssue);
|
|
171
171
|
}
|
|
@@ -21,7 +21,7 @@ export class CommentWakeHandler {
|
|
|
21
21
|
}
|
|
22
22
|
if (!triggerEventAllowed(project, normalized.triggerEvent))
|
|
23
23
|
return;
|
|
24
|
-
const issue = this.db.getIssue(project.id, normalized.issue.id);
|
|
24
|
+
const issue = this.db.issues.getIssue(project.id, normalized.issue.id);
|
|
25
25
|
if (!issue)
|
|
26
26
|
return;
|
|
27
27
|
const trimmedBody = normalized.comment.body.trim();
|
|
@@ -14,7 +14,7 @@ export class DesiredStageRecorder {
|
|
|
14
14
|
if (!normalizedIssue) {
|
|
15
15
|
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
16
16
|
}
|
|
17
|
-
const existingIssue = this.db.getIssue(params.project.id, normalizedIssue.id);
|
|
17
|
+
const existingIssue = this.db.issues.getIssue(params.project.id, normalizedIssue.id);
|
|
18
18
|
const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
|
|
19
19
|
const delegated = this.isDelegatedToPatchRelay(params.project, params.normalized);
|
|
20
20
|
const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
|
|
@@ -24,7 +24,7 @@ export class DesiredStageRecorder {
|
|
|
24
24
|
return { issue: undefined, wakeRunType: undefined, delegated };
|
|
25
25
|
}
|
|
26
26
|
const hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
|
|
27
|
-
const unresolvedBlockers = this.db.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
27
|
+
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
28
28
|
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
29
29
|
const desiredStage = decideRunIntent({
|
|
30
30
|
delegated,
|
|
@@ -62,7 +62,7 @@ export class DesiredStageRecorder {
|
|
|
62
62
|
delegated,
|
|
63
63
|
});
|
|
64
64
|
const commitIssueUpdate = () => {
|
|
65
|
-
const record = this.db.upsertIssue({
|
|
65
|
+
const record = this.db.issues.upsertIssue({
|
|
66
66
|
projectId: params.project.id,
|
|
67
67
|
linearIssueId: normalizedIssue.id,
|
|
68
68
|
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
@@ -88,7 +88,7 @@ export class DesiredStageRecorder {
|
|
|
88
88
|
};
|
|
89
89
|
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.project.id, normalizedIssue.id);
|
|
90
90
|
const issue = activeLease
|
|
91
|
-
? this.db.issueSessions.withIssueSessionLease(params.project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.upsertIssue({
|
|
91
|
+
? this.db.issueSessions.withIssueSessionLease(params.project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.issues.upsertIssue({
|
|
92
92
|
projectId: params.project.id,
|
|
93
93
|
linearIssueId: normalizedIssue.id,
|
|
94
94
|
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
@@ -160,7 +160,7 @@ export class DesiredStageRecorder {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
if (source.relationsKnown) {
|
|
163
|
-
this.db.replaceIssueDependencies({
|
|
163
|
+
this.db.issues.replaceIssueDependencies({
|
|
164
164
|
projectId,
|
|
165
165
|
linearIssueId: source.id,
|
|
166
166
|
blockers: source.blockedBy.map((blocker) => ({
|
|
@@ -9,7 +9,7 @@ export class IssueRemovalHandler {
|
|
|
9
9
|
async handle(params) {
|
|
10
10
|
if (!params.trackedIssue)
|
|
11
11
|
return;
|
|
12
|
-
const removedIssue = this.db.getIssue(params.projectId, params.issue.id);
|
|
12
|
+
const removedIssue = this.db.issues.getIssue(params.projectId, params.issue.id);
|
|
13
13
|
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.projectId, params.issue.id);
|
|
14
14
|
const commitRemoval = () => {
|
|
15
15
|
if (removedIssue?.activeRunId) {
|
|
@@ -17,7 +17,7 @@ export class IssueRemovalHandler {
|
|
|
17
17
|
if (run) {
|
|
18
18
|
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
|
|
19
19
|
}
|
|
20
|
-
return this.db.upsertIssue({
|
|
20
|
+
return this.db.issues.upsertIssue({
|
|
21
21
|
projectId: params.projectId,
|
|
22
22
|
linearIssueId: params.issue.id,
|
|
23
23
|
activeRunId: null,
|
|
@@ -26,7 +26,7 @@ export class IssueRemovalHandler {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
|
|
29
|
-
return this.db.upsertIssue({
|
|
29
|
+
return this.db.issues.upsertIssue({
|
|
30
30
|
projectId: params.projectId,
|
|
31
31
|
linearIssueId: params.issue.id,
|
|
32
32
|
pendingRunType: null,
|
package/dist/worktree-manager.js
CHANGED
|
@@ -6,6 +6,75 @@ export class WorktreeManager {
|
|
|
6
6
|
constructor(config) {
|
|
7
7
|
this.config = config;
|
|
8
8
|
}
|
|
9
|
+
async freshenWorktree(worktreePath, project, issue, logger) {
|
|
10
|
+
const gitBin = this.config.runner.gitBin;
|
|
11
|
+
const baseBranch = project.github?.baseBranch ?? "main";
|
|
12
|
+
const stashResult = await execCommand(gitBin, ["-C", worktreePath, "stash"], { timeoutMs: 30_000 });
|
|
13
|
+
const didStash = stashResult.exitCode === 0 && !stashResult.stdout?.includes("No local changes");
|
|
14
|
+
const fetchResult = await execCommand(gitBin, ["-C", worktreePath, "fetch", "origin", baseBranch], { timeoutMs: 60_000 });
|
|
15
|
+
if (fetchResult.exitCode !== 0) {
|
|
16
|
+
logger?.warn({ issueKey: issue.issueKey, stderr: fetchResult.stderr?.slice(0, 300) }, "Pre-run fetch failed, proceeding with current base");
|
|
17
|
+
if (didStash)
|
|
18
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const mergeBaseResult = await execCommand(gitBin, ["-C", worktreePath, "merge-base", "--is-ancestor", `origin/${baseBranch}`, "HEAD"], { timeoutMs: 10_000 });
|
|
22
|
+
if (mergeBaseResult.exitCode === 0) {
|
|
23
|
+
logger?.debug({ issueKey: issue.issueKey }, "Pre-run freshen: branch already up to date");
|
|
24
|
+
if (didStash)
|
|
25
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const rebaseResult = await execCommand(gitBin, ["-C", worktreePath, "rebase", `origin/${baseBranch}`], { timeoutMs: 120_000 });
|
|
29
|
+
if (rebaseResult.exitCode !== 0) {
|
|
30
|
+
await execCommand(gitBin, ["-C", worktreePath, "rebase", "--abort"], { timeoutMs: 10_000 });
|
|
31
|
+
if (didStash)
|
|
32
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
33
|
+
logger?.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
logger?.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased locally onto latest base");
|
|
37
|
+
if (didStash)
|
|
38
|
+
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
39
|
+
}
|
|
40
|
+
async resetWorktreeToTrackedBranch(worktreePath, branchName, issue, logger) {
|
|
41
|
+
const gitBin = this.config.runner.gitBin;
|
|
42
|
+
const branchFetch = await execCommand(gitBin, ["-C", worktreePath, "fetch", "origin", branchName], { timeoutMs: 60_000 });
|
|
43
|
+
const hasRemoteBranch = branchFetch.exitCode === 0;
|
|
44
|
+
await execCommand(gitBin, ["-C", worktreePath, "rebase", "--abort"], { timeoutMs: 10_000 });
|
|
45
|
+
await execCommand(gitBin, ["-C", worktreePath, "merge", "--abort"], { timeoutMs: 10_000 });
|
|
46
|
+
await execCommand(gitBin, ["-C", worktreePath, "cherry-pick", "--abort"], { timeoutMs: 10_000 });
|
|
47
|
+
await execCommand(gitBin, ["-C", worktreePath, "am", "--abort"], { timeoutMs: 10_000 });
|
|
48
|
+
await execCommand(gitBin, ["-C", worktreePath, "reset", "--hard", "HEAD"], { timeoutMs: 30_000 });
|
|
49
|
+
await execCommand(gitBin, ["-C", worktreePath, "clean", "-fd"], { timeoutMs: 30_000 });
|
|
50
|
+
const checkoutTarget = hasRemoteBranch ? `origin/${branchName}` : branchName;
|
|
51
|
+
const checkoutResult = await execCommand(gitBin, ["-C", worktreePath, "checkout", "-B", branchName, checkoutTarget], { timeoutMs: 30_000 });
|
|
52
|
+
if (checkoutResult.exitCode !== 0) {
|
|
53
|
+
throw new Error(`Failed to restore ${branchName} worktree state: ${checkoutResult.stderr?.slice(0, 300) ?? "git checkout failed"}`);
|
|
54
|
+
}
|
|
55
|
+
const resetTarget = hasRemoteBranch ? `origin/${branchName}` : "HEAD";
|
|
56
|
+
const resetResult = await execCommand(gitBin, ["-C", worktreePath, "reset", "--hard", resetTarget], { timeoutMs: 30_000 });
|
|
57
|
+
if (resetResult.exitCode !== 0) {
|
|
58
|
+
throw new Error(`Failed to reset ${branchName} worktree state: ${resetResult.stderr?.slice(0, 300) ?? "git reset failed"}`);
|
|
59
|
+
}
|
|
60
|
+
await execCommand(gitBin, ["-C", worktreePath, "clean", "-fd"], { timeoutMs: 30_000 });
|
|
61
|
+
logger?.debug({ issueKey: issue.issueKey, branchName, hasRemoteBranch }, "Reset issue worktree to tracked branch state");
|
|
62
|
+
}
|
|
63
|
+
async restoreIdleWorktree(issue, logger) {
|
|
64
|
+
if (!issue.worktreePath || !issue.branchName)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
await this.resetWorktreeToTrackedBranch(issue.worktreePath, issue.branchName, issue, logger);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
logger?.warn({
|
|
71
|
+
issueKey: issue.issueKey,
|
|
72
|
+
branchName: issue.branchName,
|
|
73
|
+
worktreePath: issue.worktreePath,
|
|
74
|
+
error: error instanceof Error ? error.message : String(error),
|
|
75
|
+
}, "Failed to restore idle worktree after interrupted run");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
9
78
|
async ensureIssueWorktree(repoPath, worktreeRoot, worktreePath, branchName, options) {
|
|
10
79
|
if (existsSync(worktreePath)) {
|
|
11
80
|
await this.assertTrustedExistingWorktree(repoPath, worktreeRoot, worktreePath, options);
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
}
|