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