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 +78 -9
- package/index.js +78 -9
- package/package.json +1 -1
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
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
|
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
|
-
|
|
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);
|