@saeed42/worktree-worker 1.3.1 → 1.3.3

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.
Files changed (2) hide show
  1. package/dist/main.js +162 -86
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -13,9 +13,10 @@ var env = {
13
13
  PORT: parseInt(process.env.PORT || "8787", 10),
14
14
  NODE_ENV: process.env.NODE_ENV || "development",
15
15
  WORKER_TOKEN: process.env.WORKER_TOKEN || "",
16
- BASE_WORKSPACE_DIR: process.env.BASE_WORKSPACE_DIR || "/project/sandbox",
17
- WORKSPACES_ROOT: process.env.WORKSPACES_ROOT || "/project/workspaces",
18
- TRIALS_WORKSPACE_DIR: process.env.TRIALS_WORKSPACE_DIR || "/project/workspaces/trials",
16
+ // CRITICAL: Must be /project/sandbox to match trigger tasks and OpenCode
17
+ BASE_WORKSPACE_DIR: "/project/sandbox",
18
+ WORKSPACES_ROOT: "/project/workspaces",
19
+ TRIALS_WORKSPACE_DIR: "/project/workspaces/trials",
19
20
  DEFAULT_BRANCH: process.env.DEFAULT_BRANCH || "main",
20
21
  GIT_TIMEOUT_MS: parseInt(process.env.GIT_TIMEOUT_MS || "60000", 10),
21
22
  CLEANUP_AFTER_HOURS: parseInt(process.env.CLEANUP_AFTER_HOURS || "24", 10)
@@ -613,57 +614,107 @@ var RepoService = class {
613
614
  if (isSameRepo) {
614
615
  log.info("Repository already initialized with same URL, fetching latest");
615
616
  await gitService.fetch("origin", repoRoot, auth);
616
- const targetBranch = options.branch || env.DEFAULT_BRANCH;
617
+ const targetBranch2 = options.branch || env.DEFAULT_BRANCH;
617
618
  const currentBranch = status.branch || "";
618
- if (targetBranch !== currentBranch) {
619
- log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
620
- const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
619
+ if (targetBranch2 !== currentBranch) {
620
+ log.info("Switching to requested branch", { from: currentBranch, to: targetBranch2 });
621
+ const checkoutResult = await gitService.exec(["checkout", targetBranch2], repoRoot);
621
622
  if (checkoutResult.code !== 0) {
622
623
  await gitService.exec(
623
- ["checkout", "-b", targetBranch, `origin/${targetBranch}`],
624
+ ["checkout", "-b", targetBranch2, `origin/${targetBranch2}`],
624
625
  repoRoot
625
626
  );
626
627
  }
627
628
  }
628
- const headSha2 = await gitService.getHeadSha(repoRoot);
629
- const branch2 = await gitService.getCurrentBranch(repoRoot);
630
- return { path: repoRoot, branch: branch2, headSha: headSha2, remote: status.remote };
629
+ const headSha3 = await gitService.getHeadSha(repoRoot);
630
+ const branch3 = await gitService.getCurrentBranch(repoRoot);
631
+ return { path: repoRoot, branch: branch3, headSha: headSha3, remote: status.remote };
631
632
  }
632
633
  if (!options.force) {
633
634
  throw new Error(
634
635
  `Repository already initialized with different URL. Current: ${currentUrl}, Requested: ${requestedUrl}. Use force=true to re-initialize (this will delete all worktrees).`
635
636
  );
636
637
  }
637
- log.warn("Force re-init: cleaning all worktrees and repo");
638
+ log.warn("Force re-init: cleaning worktrees and updating remote in-place");
638
639
  await this.cleanAllWorktrees();
639
- await rm(repoRoot, { recursive: true, force: true });
640
+ const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
641
+ await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
642
+ await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
643
+ await gitService.exec([
644
+ "config",
645
+ "--local",
646
+ "user.email",
647
+ "origin-agent[bot]@users.noreply.github.com"
648
+ ], repoRoot);
649
+ await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
650
+ const targetBranch = options.branch || env.DEFAULT_BRANCH;
651
+ log.info("Fetching from new remote", { branch: targetBranch });
652
+ await gitService.fetch("origin", repoRoot, auth);
653
+ try {
654
+ await gitService.exec(["checkout", "-B", targetBranch, `origin/${targetBranch}`, "--force"], repoRoot);
655
+ } catch {
656
+ await gitService.exec(["checkout", "-B", targetBranch, "--force"], repoRoot);
657
+ }
658
+ await gitService.exec(["branch", `--set-upstream-to=origin/${targetBranch}`, targetBranch], repoRoot).catch(
659
+ () => {
660
+ }
661
+ );
662
+ const headSha2 = await gitService.getHeadSha(repoRoot);
663
+ const branch2 = await gitService.getCurrentBranch(repoRoot);
664
+ const remote2 = await gitService.getRemoteUrl("origin", repoRoot);
665
+ log.info("Repository re-initialized in-place", { branch: branch2, headSha: headSha2 });
666
+ return { path: repoRoot, branch: branch2, headSha: headSha2, remote: remote2 };
640
667
  }
641
668
  const parentDir = repoRoot.split("/").slice(0, -1).join("/");
642
669
  await mkdir2(parentDir, { recursive: true });
643
670
  await mkdir2(env.TRIALS_WORKSPACE_DIR, { recursive: true });
671
+ const dirExists = await stat2(repoRoot).then(() => true).catch(() => false);
644
672
  const branch = options.branch || env.DEFAULT_BRANCH;
645
- log.info("Cloning repository", { branch });
646
- await gitService.cloneRepo(options.repoUrl, repoRoot, {
647
- branch,
648
- blobless: true,
649
- githubToken: options.githubToken
650
- });
651
- await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
652
- await gitService.exec([
653
- "config",
654
- "--local",
655
- "user.email",
656
- "origin-agent[bot]@users.noreply.github.com"
657
- ], repoRoot);
658
- await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
659
- const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
660
- await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
661
- log.info("Fetching all remote refs");
662
- await gitService.fetch("origin", repoRoot, auth);
663
- await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
664
- () => {
665
- }
666
- );
673
+ if (dirExists) {
674
+ log.info("Directory exists, initializing git in-place", { branch });
675
+ await gitService.exec(["init"], repoRoot);
676
+ await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
677
+ await gitService.exec([
678
+ "config",
679
+ "--local",
680
+ "user.email",
681
+ "origin-agent[bot]@users.noreply.github.com"
682
+ ], repoRoot);
683
+ await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
684
+ const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
685
+ await gitService.exec(["remote", "add", "origin", cleanUrl], repoRoot).catch(async () => {
686
+ await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
687
+ });
688
+ await gitService.fetch("origin", repoRoot, auth);
689
+ await gitService.exec(["checkout", "-B", branch, `origin/${branch}`, "--force"], repoRoot);
690
+ await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
691
+ () => {
692
+ }
693
+ );
694
+ } else {
695
+ log.info("Cloning repository", { branch });
696
+ await gitService.cloneRepo(options.repoUrl, repoRoot, {
697
+ branch,
698
+ blobless: true,
699
+ githubToken: options.githubToken
700
+ });
701
+ await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
702
+ await gitService.exec([
703
+ "config",
704
+ "--local",
705
+ "user.email",
706
+ "origin-agent[bot]@users.noreply.github.com"
707
+ ], repoRoot);
708
+ await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
709
+ const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
710
+ await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
711
+ log.info("Fetching all remote refs");
712
+ await gitService.fetch("origin", repoRoot, auth);
713
+ await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
714
+ () => {
715
+ }
716
+ );
717
+ }
667
718
  const isEmpty = await gitService.isEmptyRepo(repoRoot);
668
719
  if (isEmpty) {
669
720
  log.warn("Repository is empty (no commits)", { repoUrl: options.repoUrl });
@@ -873,6 +924,7 @@ var WorktreeService = class {
873
924
  hasRepoUrl: !!options.repoUrl
874
925
  });
875
926
  await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
927
+ await this.opportunisticCleanup();
876
928
  let repoStatus = await repoService.getStatus();
877
929
  const isValidGitHubRemote = (remote) => {
878
930
  if (!remote) return false;
@@ -1118,59 +1170,98 @@ var WorktreeService = class {
1118
1170
  return { branch, pushed: true };
1119
1171
  }
1120
1172
  /**
1121
- * Cleanup stale worktrees based on age and optionally disk pressure
1122
- *
1123
- * Strategy:
1124
- * 1. Always clean worktrees older than CLEANUP_AFTER_HOURS (default 24h)
1125
- * 2. If disk usage is high (>80%), also clean worktrees older than 6h
1126
- * 3. If disk usage is critical (>90%), clean all worktrees older than 1h
1173
+ * Count current worktrees
1127
1174
  */
1128
- async cleanupStaleWorktrees() {
1129
- const log = logger.child({ service: "worktree", action: "cleanup" });
1130
- let cleaned = 0;
1131
- const errors = [];
1132
- let diskUsagePercent;
1133
- let cutoffHours = env.CLEANUP_AFTER_HOURS;
1175
+ async countWorktrees() {
1134
1176
  try {
1135
- const diskInfo = await this.getDiskUsage();
1136
- diskUsagePercent = diskInfo.usedPercent;
1137
- if (diskUsagePercent > 90) {
1138
- cutoffHours = 1;
1139
- log.warn("Critical disk usage, aggressive cleanup", { diskUsagePercent, cutoffHours });
1140
- } else if (diskUsagePercent > 80) {
1141
- cutoffHours = 6;
1142
- log.info("High disk usage, moderate cleanup", { diskUsagePercent, cutoffHours });
1143
- }
1144
- } catch (err) {
1145
- log.warn("Could not check disk usage", { error: err instanceof Error ? err.message : String(err) });
1177
+ const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
1178
+ return entries.filter((e) => e.isDirectory()).length;
1179
+ } catch {
1180
+ return 0;
1146
1181
  }
1147
- const cutoffTime = Date.now() - cutoffHours * 60 * 60 * 1e3;
1182
+ }
1183
+ /**
1184
+ * Get worktrees sorted by modification time (oldest first)
1185
+ */
1186
+ async getWorktreesByAge() {
1148
1187
  try {
1149
1188
  const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
1150
- const worktreesWithStats = [];
1189
+ const worktrees = [];
1151
1190
  for (const entry of entries) {
1152
1191
  if (!entry.isDirectory()) continue;
1153
1192
  const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
1154
1193
  try {
1155
1194
  const stats = await stat3(worktreePath);
1156
- worktreesWithStats.push({ path: worktreePath, mtime: stats.mtimeMs });
1195
+ worktrees.push({ path: worktreePath, mtime: stats.mtimeMs });
1157
1196
  } catch {
1158
1197
  }
1159
1198
  }
1160
- worktreesWithStats.sort((a, b) => a.mtime - b.mtime);
1161
- for (const wt of worktreesWithStats) {
1162
- if (wt.mtime < cutoffTime) {
1199
+ return worktrees.sort((a, b) => a.mtime - b.mtime);
1200
+ } catch {
1201
+ return [];
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Opportunistic cleanup - run before creating new worktrees
1206
+ *
1207
+ * Strategy:
1208
+ * 1. Always clean worktrees older than CLEANUP_AFTER_HOURS
1209
+ * 2. If count exceeds MAX_WORKTREES, clean oldest until under limit
1210
+ */
1211
+ async opportunisticCleanup(maxWorktrees = 20) {
1212
+ const log = logger.child({ service: "worktree", action: "opportunistic-cleanup" });
1213
+ try {
1214
+ const worktrees = await this.getWorktreesByAge();
1215
+ const count = worktrees.length;
1216
+ const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
1217
+ let cleaned = 0;
1218
+ for (const wt of worktrees) {
1219
+ const isStale = wt.mtime < cutoffTime;
1220
+ const isOverLimit = count - cleaned > maxWorktrees;
1221
+ if (isStale || isOverLimit) {
1163
1222
  try {
1164
- log.info("Cleaning up stale worktree", {
1223
+ await rm2(wt.path, { recursive: true, force: true });
1224
+ cleaned++;
1225
+ log.debug("Cleaned worktree", {
1165
1226
  path: wt.path,
1227
+ reason: isStale ? "stale" : "over_limit",
1166
1228
  ageHours: Math.round((Date.now() - wt.mtime) / (60 * 60 * 1e3))
1167
1229
  });
1168
- await rm2(wt.path, { recursive: true, force: true });
1230
+ } catch {
1231
+ }
1232
+ }
1233
+ }
1234
+ if (cleaned > 0) {
1235
+ log.info("Opportunistic cleanup completed", { cleaned, remaining: count - cleaned });
1236
+ await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR).catch(() => {
1237
+ });
1238
+ }
1239
+ } catch {
1240
+ }
1241
+ }
1242
+ /**
1243
+ * Cleanup stale worktrees
1244
+ */
1245
+ async cleanupStaleWorktrees() {
1246
+ const log = logger.child({ service: "worktree", action: "cleanup" });
1247
+ let cleaned = 0;
1248
+ const errors = [];
1249
+ const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
1250
+ try {
1251
+ const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
1252
+ for (const entry of entries) {
1253
+ if (!entry.isDirectory()) continue;
1254
+ const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
1255
+ try {
1256
+ const stats = await stat3(worktreePath);
1257
+ if (stats.mtimeMs < cutoffTime) {
1258
+ log.info("Cleaning up stale worktree", { path: worktreePath });
1259
+ await rm2(worktreePath, { recursive: true, force: true });
1169
1260
  cleaned++;
1170
- } catch (err) {
1171
- const errMsg = err instanceof Error ? err.message : String(err);
1172
- errors.push(`${wt.path}: ${errMsg}`);
1173
1261
  }
1262
+ } catch (err) {
1263
+ const errMsg = err instanceof Error ? err.message : String(err);
1264
+ errors.push(`${worktreePath}: ${errMsg}`);
1174
1265
  }
1175
1266
  }
1176
1267
  await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
@@ -1178,23 +1269,8 @@ var WorktreeService = class {
1178
1269
  const errMsg = err instanceof Error ? err.message : String(err);
1179
1270
  errors.push(`readdir: ${errMsg}`);
1180
1271
  }
1181
- log.info("Cleanup completed", { cleaned, errorCount: errors.length, diskUsagePercent });
1182
- return { cleaned, errors, diskUsagePercent };
1183
- }
1184
- /**
1185
- * Get disk usage information for the workspace partition
1186
- */
1187
- async getDiskUsage() {
1188
- const { exec } = await import("child_process");
1189
- const { promisify } = await import("util");
1190
- const execAsync = promisify(exec);
1191
- const { stdout } = await execAsync(`df -B1 ${env.BASE_WORKSPACE_DIR} | tail -1`);
1192
- const parts = stdout.trim().split(/\s+/);
1193
- const total = parseInt(parts[1], 10);
1194
- const used = parseInt(parts[2], 10);
1195
- const free = parseInt(parts[3], 10);
1196
- const usedPercent = parseInt(parts[4].replace("%", ""), 10);
1197
- return { total, used, free, usedPercent };
1272
+ log.info("Cleanup completed", { cleaned, errorCount: errors.length });
1273
+ return { cleaned, errors };
1198
1274
  }
1199
1275
  };
1200
1276
  var worktreeService = new WorktreeService();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saeed42/worktree-worker",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Git worktree management service for AI agent trials",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",