runcastle 1.0.22 → 1.0.23

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/bin/runcastle.js CHANGED
@@ -2200,6 +2200,34 @@ function researchBranchName(slug, waypointSeq, unique) {
2200
2200
  function ticketBranchName(slug, ticketSeq, unique) {
2201
2201
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2202
2202
  }
2203
+ function burnWorktreePath(repoPath, branch) {
2204
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2205
+ }
2206
+ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2207
+ const path = burnWorktreePath(repoPath, branch);
2208
+ const attempts = Math.max(1, opts.attempts ?? 3);
2209
+ const delayMs = opts.delayMs ?? 750;
2210
+ const g = git(repoPath);
2211
+ for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2212
+ try {
2213
+ await g.raw(["worktree", "remove", "--force", path]);
2214
+ } catch {}
2215
+ if (existsSync7(path) && attempt < attempts)
2216
+ await sleep(delayMs);
2217
+ }
2218
+ if (existsSync7(path)) {
2219
+ try {
2220
+ rmSync(path, { recursive: true, force: true });
2221
+ } catch {}
2222
+ }
2223
+ try {
2224
+ await g.raw(["worktree", "prune"]);
2225
+ } catch {}
2226
+ return !existsSync7(path);
2227
+ }
2228
+ function sleep(ms) {
2229
+ return new Promise((done) => setTimeout(done, ms));
2230
+ }
2203
2231
  async function branchCommitsAhead(repoPath, base, branch) {
2204
2232
  try {
2205
2233
  const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
@@ -3058,6 +3086,12 @@ ${blockedContent.trim()}` };
3058
3086
  }
3059
3087
  return { status: "failed", error: "agent made no commits" };
3060
3088
  }
3089
+ function isWorktreeTeardownError(err) {
3090
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3091
+ if (!BURN_WORKTREE_PATH.test(msg))
3092
+ return false;
3093
+ return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
3094
+ }
3061
3095
  function isMergeConflictError(err) {
3062
3096
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
3063
3097
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
@@ -3486,13 +3520,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3486
3520
  } catch (err) {
3487
3521
  if (ctx.signal.aborted)
3488
3522
  throw err;
3489
- return {
3490
- ok: false,
3491
- branch: await bestTip(),
3492
- error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3493
- };
3523
+ const tip = await bestTip();
3524
+ const teardownOnly = !ticketAbort.signal.aborted && isWorktreeTeardownError(err) && tip === resolveBranch;
3525
+ if (!teardownOnly) {
3526
+ return {
3527
+ ok: false,
3528
+ branch: tip,
3529
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3530
+ };
3531
+ }
3532
+ await cleanupBurnWorktree(project.repoPath, resolveBranch);
3494
3533
  }
3495
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3534
+ const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
3496
3535
  if (blocked !== undefined && blocked.trim().length > 0) {
3497
3536
  return {
3498
3537
  ok: false,
@@ -3627,6 +3666,18 @@ ${retryNotes}` : basePrompt,
3627
3666
  }
3628
3667
  };
3629
3668
  }
3669
+ if (isWorktreeTeardownError(err)) {
3670
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3671
+ if (salvaged.length > 0) {
3672
+ ctx.emitEvent({
3673
+ type: "burn.worktree.teardown-failed",
3674
+ message: `ticket ${ticket.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3675
+ ticketId: ticket.id,
3676
+ data: { branch: tempBranch, error: msg, cleanedUp: removed }
3677
+ });
3678
+ return await landChain(tempBranch, salvaged);
3679
+ }
3680
+ }
3630
3681
  if (isMergeConflictError(err)) {
3631
3682
  if (salvaged.length > 0) {
3632
3683
  preserveChain(tempBranch);
@@ -3728,7 +3779,7 @@ function readTokenFromEnvFile(path) {
3728
3779
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3729
3780
  return token && token.length > 0 ? token : undefined;
3730
3781
  }
3731
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3782
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3732
3783
  var init_ticket_burner = __esm(() => {
3733
3784
  init_src();
3734
3785
  init_config_load();
@@ -3742,6 +3793,14 @@ var init_ticket_burner = __esm(() => {
3742
3793
  yarn: "~/.cache/yarn",
3743
3794
  npm: "~/.npm"
3744
3795
  };
3796
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
3797
+ WORKTREE_REMOVAL_FAILURES = [
3798
+ /failed to delete/i,
3799
+ /directory not empty|enotempty/i,
3800
+ /unable to (unlink|delete)/i,
3801
+ /is not a working tree/i,
3802
+ /resource busy|ebusy|eperm|eacces/i
3803
+ ];
3745
3804
  FATAL_ERROR_PATTERNS = [
3746
3805
  /invalid (api key|x-api-key)/i,
3747
3806
  /authentication|unauthorized|permission denied/i,
@@ -3879,16 +3938,26 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3879
3938
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3880
3939
  };
3881
3940
  let result;
3941
+ let salvaged = [];
3882
3942
  try {
3883
3943
  result = await run2(runOptions);
3884
3944
  } catch (err) {
3885
3945
  throttle.flush();
3886
3946
  if (ctx.signal.aborted)
3887
3947
  throw err;
3888
- return { status: "failed", error: err instanceof Error ? err.message : String(err) };
3948
+ const msg = err instanceof Error ? err.message : String(err);
3949
+ salvaged = isWorktreeTeardownError(err) ? await branchCommitsAhead(project.repoPath, feature.branch, tempBranch) : [];
3950
+ if (salvaged.length === 0)
3951
+ return { status: "failed", error: msg };
3952
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3953
+ ctx.emitEvent({
3954
+ type: "burn.worktree.teardown-failed",
3955
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3956
+ data: { tempBranch, error: msg, cleanedUp: removed }
3957
+ });
3889
3958
  }
3890
3959
  throttle.flush();
3891
- const outcome = interpretResearchResult(result, docRelPath);
3960
+ const outcome = result ? interpretResearchResult(result, docRelPath) : { status: "done", commits: salvaged, docRelPath };
3892
3961
  if (outcome.status !== "done")
3893
3962
  return outcome;
3894
3963
  const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
package/index.js CHANGED
@@ -2199,6 +2199,34 @@ function researchBranchName(slug, waypointSeq, unique) {
2199
2199
  function ticketBranchName(slug, ticketSeq, unique) {
2200
2200
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2201
2201
  }
2202
+ function burnWorktreePath(repoPath, branch) {
2203
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2204
+ }
2205
+ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2206
+ const path = burnWorktreePath(repoPath, branch);
2207
+ const attempts = Math.max(1, opts.attempts ?? 3);
2208
+ const delayMs = opts.delayMs ?? 750;
2209
+ const g = git(repoPath);
2210
+ for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2211
+ try {
2212
+ await g.raw(["worktree", "remove", "--force", path]);
2213
+ } catch {}
2214
+ if (existsSync7(path) && attempt < attempts)
2215
+ await sleep(delayMs);
2216
+ }
2217
+ if (existsSync7(path)) {
2218
+ try {
2219
+ rmSync(path, { recursive: true, force: true });
2220
+ } catch {}
2221
+ }
2222
+ try {
2223
+ await g.raw(["worktree", "prune"]);
2224
+ } catch {}
2225
+ return !existsSync7(path);
2226
+ }
2227
+ function sleep(ms) {
2228
+ return new Promise((done) => setTimeout(done, ms));
2229
+ }
2202
2230
  async function branchCommitsAhead(repoPath, base, branch) {
2203
2231
  try {
2204
2232
  const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
@@ -3057,6 +3085,12 @@ ${blockedContent.trim()}` };
3057
3085
  }
3058
3086
  return { status: "failed", error: "agent made no commits" };
3059
3087
  }
3088
+ function isWorktreeTeardownError(err) {
3089
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3090
+ if (!BURN_WORKTREE_PATH.test(msg))
3091
+ return false;
3092
+ return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
3093
+ }
3060
3094
  function isMergeConflictError(err) {
3061
3095
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
3062
3096
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
@@ -3485,13 +3519,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3485
3519
  } catch (err) {
3486
3520
  if (ctx.signal.aborted)
3487
3521
  throw err;
3488
- return {
3489
- ok: false,
3490
- branch: await bestTip(),
3491
- error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3492
- };
3522
+ const tip = await bestTip();
3523
+ const teardownOnly = !ticketAbort.signal.aborted && isWorktreeTeardownError(err) && tip === resolveBranch;
3524
+ if (!teardownOnly) {
3525
+ return {
3526
+ ok: false,
3527
+ branch: tip,
3528
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3529
+ };
3530
+ }
3531
+ await cleanupBurnWorktree(project.repoPath, resolveBranch);
3493
3532
  }
3494
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3533
+ const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
3495
3534
  if (blocked !== undefined && blocked.trim().length > 0) {
3496
3535
  return {
3497
3536
  ok: false,
@@ -3626,6 +3665,18 @@ ${retryNotes}` : basePrompt,
3626
3665
  }
3627
3666
  };
3628
3667
  }
3668
+ if (isWorktreeTeardownError(err)) {
3669
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3670
+ if (salvaged.length > 0) {
3671
+ ctx.emitEvent({
3672
+ type: "burn.worktree.teardown-failed",
3673
+ message: `ticket ${ticket.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3674
+ ticketId: ticket.id,
3675
+ data: { branch: tempBranch, error: msg, cleanedUp: removed }
3676
+ });
3677
+ return await landChain(tempBranch, salvaged);
3678
+ }
3679
+ }
3629
3680
  if (isMergeConflictError(err)) {
3630
3681
  if (salvaged.length > 0) {
3631
3682
  preserveChain(tempBranch);
@@ -3727,7 +3778,7 @@ function readTokenFromEnvFile(path) {
3727
3778
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3728
3779
  return token && token.length > 0 ? token : undefined;
3729
3780
  }
3730
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3781
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3731
3782
  var init_ticket_burner = __esm(() => {
3732
3783
  init_src();
3733
3784
  init_config_load();
@@ -3741,6 +3792,14 @@ var init_ticket_burner = __esm(() => {
3741
3792
  yarn: "~/.cache/yarn",
3742
3793
  npm: "~/.npm"
3743
3794
  };
3795
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
3796
+ WORKTREE_REMOVAL_FAILURES = [
3797
+ /failed to delete/i,
3798
+ /directory not empty|enotempty/i,
3799
+ /unable to (unlink|delete)/i,
3800
+ /is not a working tree/i,
3801
+ /resource busy|ebusy|eperm|eacces/i
3802
+ ];
3744
3803
  FATAL_ERROR_PATTERNS = [
3745
3804
  /invalid (api key|x-api-key)/i,
3746
3805
  /authentication|unauthorized|permission denied/i,
@@ -3878,16 +3937,26 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3878
3937
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3879
3938
  };
3880
3939
  let result;
3940
+ let salvaged = [];
3881
3941
  try {
3882
3942
  result = await run2(runOptions);
3883
3943
  } catch (err) {
3884
3944
  throttle.flush();
3885
3945
  if (ctx.signal.aborted)
3886
3946
  throw err;
3887
- return { status: "failed", error: err instanceof Error ? err.message : String(err) };
3947
+ const msg = err instanceof Error ? err.message : String(err);
3948
+ salvaged = isWorktreeTeardownError(err) ? await branchCommitsAhead(project.repoPath, feature.branch, tempBranch) : [];
3949
+ if (salvaged.length === 0)
3950
+ return { status: "failed", error: msg };
3951
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3952
+ ctx.emitEvent({
3953
+ type: "burn.worktree.teardown-failed",
3954
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3955
+ data: { tempBranch, error: msg, cleanedUp: removed }
3956
+ });
3888
3957
  }
3889
3958
  throttle.flush();
3890
- const outcome = interpretResearchResult(result, docRelPath);
3959
+ const outcome = result ? interpretResearchResult(result, docRelPath) : { status: "done", commits: salvaged, docRelPath };
3891
3960
  if (outcome.status !== "done")
3892
3961
  return outcome;
3893
3962
  const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",