patchrelay 0.36.7 → 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.
@@ -0,0 +1,68 @@
1
+ import { TERMINAL_STATES } from "../factory-state.js";
2
+ export class IssueRemovalHandler {
3
+ db;
4
+ feed;
5
+ constructor(db, feed) {
6
+ this.db = db;
7
+ this.feed = feed;
8
+ }
9
+ async handle(params) {
10
+ if (!params.trackedIssue)
11
+ return;
12
+ const removedIssue = this.db.issues.getIssue(params.projectId, params.issue.id);
13
+ const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.projectId, params.issue.id);
14
+ const commitRemoval = () => {
15
+ if (removedIssue?.activeRunId) {
16
+ const run = this.db.runs.getRunById(removedIssue.activeRunId);
17
+ if (run) {
18
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
19
+ }
20
+ return this.db.issues.upsertIssue({
21
+ projectId: params.projectId,
22
+ linearIssueId: params.issue.id,
23
+ activeRunId: null,
24
+ pendingRunType: null,
25
+ factoryState: "failed",
26
+ });
27
+ }
28
+ if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
29
+ return this.db.issues.upsertIssue({
30
+ projectId: params.projectId,
31
+ linearIssueId: params.issue.id,
32
+ pendingRunType: null,
33
+ factoryState: "failed",
34
+ });
35
+ }
36
+ return removedIssue;
37
+ };
38
+ if (removedIssue?.activeRunId) {
39
+ const run = this.db.runs.getRunById(removedIssue.activeRunId);
40
+ if (run) {
41
+ await params.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
42
+ }
43
+ }
44
+ if (activeLease) {
45
+ this.db.issueSessions.withIssueSessionLease(params.projectId, params.issue.id, activeLease.leaseId, commitRemoval);
46
+ }
47
+ else {
48
+ commitRemoval();
49
+ }
50
+ this.db.issueSessions.appendIssueSessionEvent({
51
+ projectId: params.projectId,
52
+ linearIssueId: params.issue.id,
53
+ eventType: "issue_removed",
54
+ dedupeKey: `issue_removed:${params.issue.id}`,
55
+ });
56
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.projectId, params.issue.id);
57
+ this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(params.projectId, params.issue.id);
58
+ this.feed?.publish({
59
+ level: "warn",
60
+ kind: "stage",
61
+ issueKey: params.issue.identifier,
62
+ projectId: params.projectId,
63
+ stage: "failed",
64
+ status: "issue_removed",
65
+ summary: "Issue removed from Linear",
66
+ });
67
+ }
68
+ }
@@ -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.7",
3
+ "version": "0.36.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {