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.
@@ -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 = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
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 = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
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 = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
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 elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
106
- const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
107
- if (elapsed < delay) {
108
- this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
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 = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
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 params.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
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 = params.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
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 = params.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
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 = params.getHeldLease(run.projectId, run.linearIssueId);
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
  }
@@ -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)
@@ -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,
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.8",
3
+ "version": "0.36.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {