runcastle 1.0.21 → 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 +337 -57
- package/drizzle/0009_pale_agent_zero.sql +1 -0
- package/drizzle/meta/0009_snapshot.json +610 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +337 -57
- package/package.json +1 -1
- package/skills/burner/resolve-conflict.md +69 -0
- package/web/assets/{index-B5UytNW_.js → index-BcWKL2hj.js} +20 -20
- package/web/assets/{index-BIDKY6fS.css → index-fWdYT4eC.css} +1 -1
- package/web/index.html +2 -2
package/index.js
CHANGED
|
@@ -106,6 +106,7 @@ var init_config = __esm(() => {
|
|
|
106
106
|
burnConcurrency: z.number().int().min(1).max(8).default(3),
|
|
107
107
|
burnMaxIterations: z.number().int().min(1).max(10).default(3),
|
|
108
108
|
burnAttempts: z.number().int().min(1).max(5).default(3),
|
|
109
|
+
burnConflictAttempts: z.number().int().min(0).max(3).default(2),
|
|
109
110
|
setupCommand: z.string().optional(),
|
|
110
111
|
burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
|
|
111
112
|
}));
|
|
@@ -149,6 +150,9 @@ function loadConfig(env = process.env) {
|
|
|
149
150
|
if (env.RUNCASTLE_BURN_ATTEMPTS) {
|
|
150
151
|
overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
|
|
151
152
|
}
|
|
153
|
+
if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
|
|
154
|
+
overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
|
|
155
|
+
}
|
|
152
156
|
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
153
157
|
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
154
158
|
if (env.RUNCASTLE_BURN_WORKSPACE)
|
|
@@ -215,7 +219,8 @@ var init_schemas = __esm(() => {
|
|
|
215
219
|
status: TicketStatus,
|
|
216
220
|
commits: z2.array(z2.string()),
|
|
217
221
|
error: z2.string().optional(),
|
|
218
|
-
attemptBranch: z2.string().optional()
|
|
222
|
+
attemptBranch: z2.string().optional(),
|
|
223
|
+
conflictFiles: z2.array(z2.string()).optional()
|
|
219
224
|
});
|
|
220
225
|
WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
|
|
221
226
|
WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
|
|
@@ -490,7 +495,8 @@ var init_db_schema = __esm(() => {
|
|
|
490
495
|
status: text("status").notNull().$type(),
|
|
491
496
|
commits: text("commits", { mode: "json" }).notNull().$type(),
|
|
492
497
|
error: text("error"),
|
|
493
|
-
attemptBranch: text("attempt_branch")
|
|
498
|
+
attemptBranch: text("attempt_branch"),
|
|
499
|
+
conflictFiles: text("conflict_files", { mode: "json" }).$type()
|
|
494
500
|
});
|
|
495
501
|
waypoints = sqliteTable("waypoints", {
|
|
496
502
|
id: text("id").primaryKey(),
|
|
@@ -1706,7 +1712,8 @@ function rowToTicket(row) {
|
|
|
1706
1712
|
status: row.status,
|
|
1707
1713
|
commits: row.commits,
|
|
1708
1714
|
error: row.error ?? undefined,
|
|
1709
|
-
attemptBranch: row.attemptBranch ?? undefined
|
|
1715
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
1716
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
1710
1717
|
};
|
|
1711
1718
|
}
|
|
1712
1719
|
function resolveBlocking(inputs, startSeq) {
|
|
@@ -1740,7 +1747,8 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1740
1747
|
status: "pending",
|
|
1741
1748
|
commits: [],
|
|
1742
1749
|
error: null,
|
|
1743
|
-
attemptBranch: null
|
|
1750
|
+
attemptBranch: null,
|
|
1751
|
+
conflictFiles: null
|
|
1744
1752
|
}));
|
|
1745
1753
|
ctx.db.insert(tickets).values(rows).run();
|
|
1746
1754
|
emit(ctx, featureId, {
|
|
@@ -1812,6 +1820,8 @@ function updateTicket(ctx, id, patch) {
|
|
|
1812
1820
|
set.error = patch.error;
|
|
1813
1821
|
if (patch.attemptBranch !== undefined)
|
|
1814
1822
|
set.attemptBranch = patch.attemptBranch;
|
|
1823
|
+
if (patch.conflictFiles !== undefined)
|
|
1824
|
+
set.conflictFiles = patch.conflictFiles;
|
|
1815
1825
|
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1816
1826
|
emit(ctx, current2.featureId, {
|
|
1817
1827
|
type: "ticket.updated",
|
|
@@ -2189,6 +2199,34 @@ function researchBranchName(slug, waypointSeq, unique) {
|
|
|
2189
2199
|
function ticketBranchName(slug, ticketSeq, unique) {
|
|
2190
2200
|
return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
|
|
2191
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
|
+
}
|
|
2192
2230
|
async function branchCommitsAhead(repoPath, base, branch) {
|
|
2193
2231
|
try {
|
|
2194
2232
|
const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
|
|
@@ -2198,6 +2236,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2198
2236
|
return [];
|
|
2199
2237
|
}
|
|
2200
2238
|
}
|
|
2239
|
+
async function commitSummaries(repoPath, base, branch, limit = 20) {
|
|
2240
|
+
try {
|
|
2241
|
+
const out = await git(repoPath).raw([
|
|
2242
|
+
"log",
|
|
2243
|
+
`--max-count=${limit}`,
|
|
2244
|
+
"--format=%h %s",
|
|
2245
|
+
`${base}..${branch}`
|
|
2246
|
+
]);
|
|
2247
|
+
return out.split(`
|
|
2248
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
2249
|
+
} catch {
|
|
2250
|
+
return [];
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2201
2253
|
async function deleteTempBranch(repoPath, branch) {
|
|
2202
2254
|
try {
|
|
2203
2255
|
return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
@@ -2260,8 +2312,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
|
2260
2312
|
await gw.merge([tempBranch]);
|
|
2261
2313
|
} catch (e) {
|
|
2262
2314
|
if (await mergeInProgress(gw)) {
|
|
2315
|
+
const files = await conflictedFiles(gw);
|
|
2263
2316
|
await gw.raw(["merge", "--abort"]);
|
|
2264
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2317
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2265
2318
|
}
|
|
2266
2319
|
return { ok: false, error: errMsg(e) };
|
|
2267
2320
|
}
|
|
@@ -2292,8 +2345,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
|
|
|
2292
2345
|
await gw.merge([tempBranch]);
|
|
2293
2346
|
} catch (e) {
|
|
2294
2347
|
if (await mergeInProgress(gw)) {
|
|
2348
|
+
const files = await conflictedFiles(gw);
|
|
2295
2349
|
await gw.raw(["merge", "--abort"]);
|
|
2296
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2350
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2297
2351
|
}
|
|
2298
2352
|
return { ok: false, error: errMsg(e) };
|
|
2299
2353
|
}
|
|
@@ -2810,13 +2864,16 @@ function detectCycle(tickets2) {
|
|
|
2810
2864
|
}
|
|
2811
2865
|
return null;
|
|
2812
2866
|
}
|
|
2813
|
-
function
|
|
2867
|
+
function renderTemplate(template, values) {
|
|
2814
2868
|
let out = template;
|
|
2815
|
-
for (const key of
|
|
2816
|
-
out = out.split(`{{${key}}}`).join(
|
|
2869
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2870
|
+
out = out.split(`{{${key}}}`).join(value);
|
|
2817
2871
|
}
|
|
2818
2872
|
return out;
|
|
2819
2873
|
}
|
|
2874
|
+
function renderTicketPrompt(template, values) {
|
|
2875
|
+
return renderTemplate(template, values);
|
|
2876
|
+
}
|
|
2820
2877
|
function buildTicketJson(ticket) {
|
|
2821
2878
|
return JSON.stringify({
|
|
2822
2879
|
seq: ticket.seq,
|
|
@@ -2850,6 +2907,23 @@ ${f.content.trim()}`).join(`
|
|
|
2850
2907
|
|
|
2851
2908
|
`);
|
|
2852
2909
|
}
|
|
2910
|
+
function buildConflictFilesBlock(files) {
|
|
2911
|
+
if (files.length === 0) {
|
|
2912
|
+
return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
|
|
2913
|
+
}
|
|
2914
|
+
return files.map((f) => `- \`${f}\``).join(`
|
|
2915
|
+
`);
|
|
2916
|
+
}
|
|
2917
|
+
function buildOtherSideBlock(summaries) {
|
|
2918
|
+
if (summaries.length === 0) {
|
|
2919
|
+
return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
|
|
2920
|
+
}
|
|
2921
|
+
return summaries.map((s) => `- ${s}`).join(`
|
|
2922
|
+
`);
|
|
2923
|
+
}
|
|
2924
|
+
function resolveMergeCommand(mode, featureBranch2) {
|
|
2925
|
+
return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
|
|
2926
|
+
}
|
|
2853
2927
|
function parseEnvFile(content) {
|
|
2854
2928
|
const out = {};
|
|
2855
2929
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
@@ -3011,6 +3085,12 @@ ${blockedContent.trim()}` };
|
|
|
3011
3085
|
}
|
|
3012
3086
|
return { status: "failed", error: "agent made no commits" };
|
|
3013
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
|
+
}
|
|
3014
3094
|
function isMergeConflictError(err) {
|
|
3015
3095
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
3016
3096
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
@@ -3074,6 +3154,42 @@ function createSerialQueue() {
|
|
|
3074
3154
|
return next;
|
|
3075
3155
|
};
|
|
3076
3156
|
}
|
|
3157
|
+
async function landWithResolve(branch, deps) {
|
|
3158
|
+
let current2 = branch;
|
|
3159
|
+
for (let attempt = 1;; attempt++) {
|
|
3160
|
+
const merge = await deps.merge(current2);
|
|
3161
|
+
if (merge.ok)
|
|
3162
|
+
return { status: "landed", branch: current2 };
|
|
3163
|
+
const detail = merge.error ?? "merge failed";
|
|
3164
|
+
if (!merge.conflict)
|
|
3165
|
+
return { status: "failed", branch: current2, error: detail };
|
|
3166
|
+
const files = merge.files ?? [];
|
|
3167
|
+
if (attempt > deps.maxResolveAttempts) {
|
|
3168
|
+
return { status: "conflict", branch: current2, files, error: detail };
|
|
3169
|
+
}
|
|
3170
|
+
const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
|
|
3171
|
+
deps.emit({
|
|
3172
|
+
type: "merge.conflict.resolving",
|
|
3173
|
+
message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
|
|
3174
|
+
data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
|
|
3175
|
+
});
|
|
3176
|
+
const resolved = await deps.resolve({
|
|
3177
|
+
branch: current2,
|
|
3178
|
+
files,
|
|
3179
|
+
attempt,
|
|
3180
|
+
maxAttempts: deps.maxResolveAttempts
|
|
3181
|
+
});
|
|
3182
|
+
current2 = resolved.branch;
|
|
3183
|
+
if (!resolved.ok) {
|
|
3184
|
+
return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
|
|
3185
|
+
}
|
|
3186
|
+
deps.emit({
|
|
3187
|
+
type: "merge.conflict.resolved",
|
|
3188
|
+
message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
|
|
3189
|
+
data: { branch: current2, files, attempt }
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3077
3193
|
function errorHeadline(s) {
|
|
3078
3194
|
const lines = s.split(`
|
|
3079
3195
|
`).map((l) => l.trim()).filter(Boolean);
|
|
@@ -3221,8 +3337,14 @@ async function burnRun(ctx, deps) {
|
|
|
3221
3337
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
3222
3338
|
}
|
|
3223
3339
|
function burnerTemplatePath() {
|
|
3340
|
+
return burnerAssetPath("implement-ticket.md");
|
|
3341
|
+
}
|
|
3342
|
+
function resolverTemplatePath() {
|
|
3343
|
+
return burnerAssetPath("resolve-conflict.md");
|
|
3344
|
+
}
|
|
3345
|
+
function burnerAssetPath(file) {
|
|
3224
3346
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3225
|
-
return join9(resolveSkillsRoot(here), "burner",
|
|
3347
|
+
return join9(resolveSkillsRoot(here), "burner", file);
|
|
3226
3348
|
}
|
|
3227
3349
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3228
3350
|
const worktree = worktreeDir(projectId, slug);
|
|
@@ -3286,13 +3408,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3286
3408
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3287
3409
|
if (workspaceMode === "isolated")
|
|
3288
3410
|
await ensureIsolatedPushTarget();
|
|
3289
|
-
const
|
|
3290
|
-
const
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3411
|
+
const ticketJson = buildTicketJson(ticket);
|
|
3412
|
+
const featureBrief = buildFeatureBrief(feature);
|
|
3413
|
+
const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
|
|
3414
|
+
const workspaceNotes = buildWorkspaceNotes(workspaceMode);
|
|
3415
|
+
const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
|
|
3416
|
+
TICKET_JSON: ticketJson,
|
|
3417
|
+
FEATURE_BRIEF: featureBrief,
|
|
3418
|
+
DOCS_DIGEST: docsDigest,
|
|
3294
3419
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
|
|
3295
|
-
WORKSPACE_NOTES:
|
|
3420
|
+
WORKSPACE_NOTES: workspaceNotes
|
|
3296
3421
|
});
|
|
3297
3422
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3298
3423
|
const pm = detectPackageManager(toolchain);
|
|
@@ -3325,9 +3450,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3325
3450
|
activeTicketAborts.set(ticket.id, ticketAbort);
|
|
3326
3451
|
const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
|
|
3327
3452
|
const maxAttempts = Math.max(1, config2.burnAttempts);
|
|
3453
|
+
const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
|
|
3328
3454
|
let baseBranch = feature.branch;
|
|
3329
3455
|
let retryNotes;
|
|
3330
|
-
if (ticket.attemptBranch) {
|
|
3456
|
+
if (ticket.attemptBranch && !conflictResume) {
|
|
3331
3457
|
const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
|
|
3332
3458
|
if (preserved.length > 0) {
|
|
3333
3459
|
baseBranch = ticket.attemptBranch;
|
|
@@ -3346,7 +3472,145 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3346
3472
|
const preserveChain = (branch) => {
|
|
3347
3473
|
ctx.updateTicket(ticket.id, { attemptBranch: branch });
|
|
3348
3474
|
};
|
|
3475
|
+
const runResolver = async (input) => {
|
|
3476
|
+
const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
|
|
3477
|
+
const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
|
|
3478
|
+
const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
|
|
3479
|
+
TICKET_JSON: ticketJson,
|
|
3480
|
+
FEATURE_BRIEF: featureBrief,
|
|
3481
|
+
DOCS_DIGEST: docsDigest,
|
|
3482
|
+
WORKSPACE_NOTES: workspaceNotes,
|
|
3483
|
+
FEATURE_BRANCH: feature.branch,
|
|
3484
|
+
CONFLICT_FILES: buildConflictFilesBlock(input.files),
|
|
3485
|
+
OTHER_SIDE: buildOtherSideBlock(otherSide),
|
|
3486
|
+
MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
|
|
3487
|
+
});
|
|
3488
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
|
|
3489
|
+
appendTranscript(ticket.id, {
|
|
3490
|
+
kind: "text",
|
|
3491
|
+
text: `
|
|
3492
|
+
\u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
|
|
3493
|
+
`
|
|
3494
|
+
});
|
|
3495
|
+
const bestTip = async () => {
|
|
3496
|
+
const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
|
|
3497
|
+
return made.length > 0 ? resolveBranch : input.branch;
|
|
3498
|
+
};
|
|
3499
|
+
let result;
|
|
3500
|
+
try {
|
|
3501
|
+
result = await run({
|
|
3502
|
+
agent: buildBurnAgent(config2, token, model),
|
|
3503
|
+
sandbox: selectSandbox(config2, mounts),
|
|
3504
|
+
cwd: project.repoPath,
|
|
3505
|
+
prompt,
|
|
3506
|
+
branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
|
|
3507
|
+
signal,
|
|
3508
|
+
name: `ticket-${ticket.seq}-resolve`,
|
|
3509
|
+
maxIterations: config2.burnMaxIterations,
|
|
3510
|
+
...hookCommand ? {
|
|
3511
|
+
hooks: {
|
|
3512
|
+
sandbox: {
|
|
3513
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
} : {},
|
|
3517
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
3518
|
+
});
|
|
3519
|
+
} catch (err) {
|
|
3520
|
+
if (ctx.signal.aborted)
|
|
3521
|
+
throw err;
|
|
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);
|
|
3532
|
+
}
|
|
3533
|
+
const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
|
|
3534
|
+
if (blocked !== undefined && blocked.trim().length > 0) {
|
|
3535
|
+
return {
|
|
3536
|
+
ok: false,
|
|
3537
|
+
branch: await bestTip(),
|
|
3538
|
+
error: `resolver reported BLOCKED:
|
|
3539
|
+
${blocked.trim()}`
|
|
3540
|
+
};
|
|
3541
|
+
}
|
|
3542
|
+
const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
|
|
3543
|
+
if (missing.length > 0) {
|
|
3544
|
+
return {
|
|
3545
|
+
ok: false,
|
|
3546
|
+
branch: await bestTip(),
|
|
3547
|
+
error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
|
|
3548
|
+
};
|
|
3549
|
+
}
|
|
3550
|
+
return { ok: true, branch: resolveBranch };
|
|
3551
|
+
};
|
|
3552
|
+
let landedCommits = [];
|
|
3553
|
+
const landDeps = {
|
|
3554
|
+
merge: (branch) => land(async () => {
|
|
3555
|
+
const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3556
|
+
const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
|
|
3557
|
+
if (res.ok)
|
|
3558
|
+
landedCommits = ahead;
|
|
3559
|
+
return res;
|
|
3560
|
+
}),
|
|
3561
|
+
resolve: runResolver,
|
|
3562
|
+
maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
|
|
3563
|
+
emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
|
|
3564
|
+
label: `ticket ${ticket.seq}`,
|
|
3565
|
+
featureBranch: feature.branch
|
|
3566
|
+
};
|
|
3567
|
+
const landChain = async (branch, commits) => {
|
|
3568
|
+
const landing = await landWithResolve(branch, landDeps);
|
|
3569
|
+
if (landing.status === "landed") {
|
|
3570
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3571
|
+
return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
|
|
3572
|
+
}
|
|
3573
|
+
preserveChain(landing.branch);
|
|
3574
|
+
if (landing.status === "conflict") {
|
|
3575
|
+
ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
|
|
3576
|
+
const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
|
|
3577
|
+
const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
|
|
3578
|
+
return {
|
|
3579
|
+
status: "failed",
|
|
3580
|
+
error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
|
|
3581
|
+
event: {
|
|
3582
|
+
type: "merge.conflict.needs-human",
|
|
3583
|
+
message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
|
|
3584
|
+
}
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
return {
|
|
3588
|
+
status: "failed",
|
|
3589
|
+
error: `ticket ${ticket.seq} committed to ${landing.branch} but landing on ${feature.branch} failed: ${errorHeadline(landing.error)} \u2014 the branch is preserved for manual recovery`
|
|
3590
|
+
};
|
|
3591
|
+
};
|
|
3349
3592
|
try {
|
|
3593
|
+
if (conflictResume) {
|
|
3594
|
+
const branch = ticket.attemptBranch;
|
|
3595
|
+
const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3596
|
+
if (pending.length === 0) {
|
|
3597
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3598
|
+
ctx.emitEvent({
|
|
3599
|
+
type: "merge.conflict.resolved",
|
|
3600
|
+
message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
|
|
3601
|
+
ticketId: ticket.id,
|
|
3602
|
+
data: { branch }
|
|
3603
|
+
});
|
|
3604
|
+
return { status: "done", commits: ticket.commits };
|
|
3605
|
+
}
|
|
3606
|
+
ctx.emitEvent({
|
|
3607
|
+
type: "ticket.resuming",
|
|
3608
|
+
message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
|
|
3609
|
+
ticketId: ticket.id,
|
|
3610
|
+
data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
|
|
3611
|
+
});
|
|
3612
|
+
return await landChain(branch, pending);
|
|
3613
|
+
}
|
|
3350
3614
|
let result;
|
|
3351
3615
|
let tempBranch = "";
|
|
3352
3616
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3401,13 +3665,29 @@ ${retryNotes}` : basePrompt,
|
|
|
3401
3665
|
}
|
|
3402
3666
|
};
|
|
3403
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
|
+
}
|
|
3404
3680
|
if (isMergeConflictError(err)) {
|
|
3681
|
+
if (salvaged.length > 0) {
|
|
3682
|
+
preserveChain(tempBranch);
|
|
3683
|
+
ctx.updateTicket(ticket.id, { conflictFiles: [] });
|
|
3684
|
+
}
|
|
3405
3685
|
return {
|
|
3406
3686
|
status: "failed",
|
|
3407
3687
|
error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
|
|
3408
3688
|
event: {
|
|
3409
3689
|
type: "merge.conflict.needs-human",
|
|
3410
|
-
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
|
|
3690
|
+
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch}${salvaged.length > 0 ? " \u2014 resolve it from the run lane" : " \u2014 resolve manually per the error, then re-burn"}`
|
|
3411
3691
|
}
|
|
3412
3692
|
};
|
|
3413
3693
|
}
|
|
@@ -3465,28 +3745,7 @@ ${blocked.trim()}` };
|
|
|
3465
3745
|
preserveChain(tempBranch);
|
|
3466
3746
|
return outcome;
|
|
3467
3747
|
}
|
|
3468
|
-
|
|
3469
|
-
if (!merge.ok) {
|
|
3470
|
-
const detail = merge.error ?? "merge failed";
|
|
3471
|
-
if (merge.conflict) {
|
|
3472
|
-
return {
|
|
3473
|
-
status: "failed",
|
|
3474
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
3475
|
-
event: {
|
|
3476
|
-
type: "merge.conflict.needs-human",
|
|
3477
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3478
|
-
}
|
|
3479
|
-
};
|
|
3480
|
-
}
|
|
3481
|
-
preserveChain(tempBranch);
|
|
3482
|
-
return {
|
|
3483
|
-
status: "failed",
|
|
3484
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3485
|
-
};
|
|
3486
|
-
}
|
|
3487
|
-
if (ticket.attemptBranch)
|
|
3488
|
-
ctx.updateTicket(ticket.id, { attemptBranch: null });
|
|
3489
|
-
return outcome;
|
|
3748
|
+
return await landChain(tempBranch, outcome.commits);
|
|
3490
3749
|
} finally {
|
|
3491
3750
|
activeTicketAborts.delete(ticket.id);
|
|
3492
3751
|
throttle.flush();
|
|
@@ -3519,7 +3778,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3519
3778
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3520
3779
|
return token && token.length > 0 ? token : undefined;
|
|
3521
3780
|
}
|
|
3522
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
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;
|
|
3523
3782
|
var init_ticket_burner = __esm(() => {
|
|
3524
3783
|
init_src();
|
|
3525
3784
|
init_config_load();
|
|
@@ -3527,19 +3786,20 @@ var init_ticket_burner = __esm(() => {
|
|
|
3527
3786
|
init_skills_root();
|
|
3528
3787
|
init_agent_stream();
|
|
3529
3788
|
init_git();
|
|
3530
|
-
PLACEHOLDERS = [
|
|
3531
|
-
"TICKET_JSON",
|
|
3532
|
-
"FEATURE_BRIEF",
|
|
3533
|
-
"DOCS_DIGEST",
|
|
3534
|
-
"COMMIT_CONVENTION",
|
|
3535
|
-
"WORKSPACE_NOTES"
|
|
3536
|
-
];
|
|
3537
3789
|
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3538
3790
|
PM_CACHE_SANDBOX_PATHS = {
|
|
3539
3791
|
bun: "~/.bun/install/cache",
|
|
3540
3792
|
yarn: "~/.cache/yarn",
|
|
3541
3793
|
npm: "~/.npm"
|
|
3542
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
|
+
];
|
|
3543
3803
|
FATAL_ERROR_PATTERNS = [
|
|
3544
3804
|
/invalid (api key|x-api-key)/i,
|
|
3545
3805
|
/authentication|unauthorized|permission denied/i,
|
|
@@ -3624,7 +3884,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
|
|
|
3624
3884
|
}
|
|
3625
3885
|
function renderResearchPrompt(template, values) {
|
|
3626
3886
|
let out = template;
|
|
3627
|
-
for (const key of
|
|
3887
|
+
for (const key of PLACEHOLDERS) {
|
|
3628
3888
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
3629
3889
|
}
|
|
3630
3890
|
return out;
|
|
@@ -3677,16 +3937,26 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
3677
3937
|
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
3678
3938
|
};
|
|
3679
3939
|
let result;
|
|
3940
|
+
let salvaged = [];
|
|
3680
3941
|
try {
|
|
3681
3942
|
result = await run2(runOptions);
|
|
3682
3943
|
} catch (err) {
|
|
3683
3944
|
throttle.flush();
|
|
3684
3945
|
if (ctx.signal.aborted)
|
|
3685
3946
|
throw err;
|
|
3686
|
-
|
|
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
|
+
});
|
|
3687
3957
|
}
|
|
3688
3958
|
throttle.flush();
|
|
3689
|
-
const outcome = interpretResearchResult(result, docRelPath);
|
|
3959
|
+
const outcome = result ? interpretResearchResult(result, docRelPath) : { status: "done", commits: salvaged, docRelPath };
|
|
3690
3960
|
if (outcome.status !== "done")
|
|
3691
3961
|
return outcome;
|
|
3692
3962
|
const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
|
|
@@ -3726,7 +3996,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
3726
3996
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3727
3997
|
return token && token.length > 0 ? token : undefined;
|
|
3728
3998
|
}
|
|
3729
|
-
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
3999
|
+
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
|
|
3730
4000
|
var init_research = __esm(() => {
|
|
3731
4001
|
init_src();
|
|
3732
4002
|
init_config_load();
|
|
@@ -3734,7 +4004,7 @@ var init_research = __esm(() => {
|
|
|
3734
4004
|
init_skills_root();
|
|
3735
4005
|
init_git();
|
|
3736
4006
|
init_ticket_burner();
|
|
3737
|
-
|
|
4007
|
+
PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
3738
4008
|
research = {
|
|
3739
4009
|
id: "research",
|
|
3740
4010
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4074,24 +4344,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
|
|
|
4074
4344
|
preservedCommits = found.commits.length;
|
|
4075
4345
|
}
|
|
4076
4346
|
}
|
|
4347
|
+
const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
|
|
4077
4348
|
for (const t of toReset.values()) {
|
|
4078
4349
|
const isTarget = t.id === ticket.id;
|
|
4079
4350
|
updateTicket(ctx, t.id, {
|
|
4080
4351
|
status: "pending",
|
|
4081
4352
|
error: null,
|
|
4082
|
-
...isTarget ? { attemptBranch: resumedFrom } : {}
|
|
4353
|
+
...isTarget ? { attemptBranch: resumedFrom } : {},
|
|
4354
|
+
...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
|
|
4083
4355
|
});
|
|
4084
4356
|
}
|
|
4085
4357
|
const seqs = [...toReset.keys()].sort((a, b) => a - b);
|
|
4086
|
-
const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
4358
|
+
const how = opts.fresh ? " from scratch" : resolvingConflict ? ` \u2014 resolving its conflict with ${feature.branch} (${preservedCommits} commit(s) preserved on ${resumedFrom})` : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
4087
4359
|
emit(ctx, feature.id, {
|
|
4088
4360
|
type: "ticket.retry",
|
|
4089
4361
|
message: seqs.length > 1 ? `retrying ticket ${ticket.seq}${how} (+ failed blocker${seqs.length > 2 ? "s" : ""} ${seqs.filter((s) => s !== ticket.seq).join(", ")})` : `retrying ticket ${ticket.seq}${how}`,
|
|
4090
4362
|
ticketId: ticket.id,
|
|
4091
|
-
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
|
|
4363
|
+
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
|
|
4092
4364
|
});
|
|
4093
4365
|
const { runId } = await burn(ctx, feature.id, { resetFailed: false });
|
|
4094
|
-
return { runId, retried: seqs, resumedFrom, preservedCommits };
|
|
4366
|
+
return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
|
|
4095
4367
|
}
|
|
4096
4368
|
function escalateToMap(ctx, featureId, input) {
|
|
4097
4369
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -6052,6 +6324,14 @@ var init_settings = __esm(() => {
|
|
|
6052
6324
|
valueSchema: z9.number().int().min(1).max(5),
|
|
6053
6325
|
parseEnv: (raw) => Number(raw)
|
|
6054
6326
|
},
|
|
6327
|
+
{
|
|
6328
|
+
key: "burnConflictAttempts",
|
|
6329
|
+
configKey: "burnConflictAttempts",
|
|
6330
|
+
envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
|
|
6331
|
+
restartRequired: false,
|
|
6332
|
+
valueSchema: z9.number().int().min(0).max(3),
|
|
6333
|
+
parseEnv: (raw) => Number(raw)
|
|
6334
|
+
},
|
|
6055
6335
|
{
|
|
6056
6336
|
key: "setupCommand",
|
|
6057
6337
|
configKey: "setupCommand",
|