patchrelay 0.36.8 → 0.36.9

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.
@@ -5,14 +5,20 @@ 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,
@@ -109,7 +115,7 @@ export class RunRecoveryService {
109
115
  return;
110
116
  }
111
117
  }
112
- const requeued = params.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
118
+ const requeued = this.withHeldLease(fresh.projectId, fresh.linearIssueId, (lease) => {
113
119
  this.db.issueSessions.upsertIssueWithLease(lease, {
114
120
  projectId: fresh.projectId,
115
121
  linearIssueId: fresh.linearIssueId,
@@ -118,7 +124,7 @@ export class RunRecoveryService {
118
124
  zombieRecoveryAttempts: attempts,
119
125
  lastZombieRecoveryAt: new Date().toISOString(),
120
126
  });
121
- return params.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
127
+ return this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
122
128
  });
123
129
  if (!requeued) {
124
130
  this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
@@ -131,7 +137,7 @@ export class RunRecoveryService {
131
137
  escalate(params) {
132
138
  const { issue, runType, reason } = params;
133
139
  this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
134
- const escalated = params.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
140
+ const escalated = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
135
141
  if (issue.activeRunId) {
136
142
  this.db.issueSessions.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
137
143
  }
@@ -160,7 +166,7 @@ export class RunRecoveryService {
160
166
  status: "escalated",
161
167
  summary: `Escalated: ${reason}`,
162
168
  });
163
- const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
169
+ const escalatedIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
164
170
  void this.linearSync.emitActivity(escalatedIssue, {
165
171
  type: "error",
166
172
  body: `PatchRelay needs human help to continue.\n\n${reason}`,
@@ -170,12 +176,12 @@ export class RunRecoveryService {
170
176
  }
171
177
  failRunAndClear(params) {
172
178
  const { run, message, nextState } = params;
173
- const updated = params.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
179
+ const updated = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
174
180
  this.db.runs.finishRun(run.id, { status: "failed", failureReason: message });
175
181
  if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
176
182
  this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
177
183
  }
178
- this.db.upsertIssue({
184
+ this.db.issues.upsertIssue({
179
185
  projectId: run.projectId,
180
186
  linearIssueId: run.linearIssueId,
181
187
  activeRunId: null,
@@ -183,7 +189,7 @@ export class RunRecoveryService {
183
189
  });
184
190
  const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
185
191
  if (branchOwner) {
186
- const heldLease = params.getHeldLease(run.projectId, run.linearIssueId);
192
+ const heldLease = this.getHeldLease(run.projectId, run.linearIssueId);
187
193
  if (heldLease) {
188
194
  this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
189
195
  }
@@ -196,7 +202,7 @@ export class RunRecoveryService {
196
202
  this.releaseLease(run.projectId, run.linearIssueId);
197
203
  }
198
204
  emitInterruptedFailure(runType, issue, message) {
199
- const latest = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
205
+ const latest = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
200
206
  void this.linearSync.emitActivity(latest, buildRunFailureActivity(runType, message));
201
207
  void this.linearSync.syncSession(latest, { activeRunType: runType });
202
208
  }
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.8",
3
+ "version": "0.36.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {