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 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 renderTicketPrompt(template, values) {
2868
+ function renderTemplate(template, values) {
2815
2869
  let out = template;
2816
- for (const key of PLACEHOLDERS) {
2817
- out = out.split(`{{${key}}}`).join(values[key]);
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", "implement-ticket.md");
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 template = readFileSync4(burnerTemplatePath(), "utf8");
3291
- const basePrompt = renderTicketPrompt(template, {
3292
- TICKET_JSON: buildTicketJson(ticket),
3293
- FEATURE_BRIEF: buildFeatureBrief(feature),
3294
- DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
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: buildWorkspaceNotes(workspaceMode)
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
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
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", PLACEHOLDERS, 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;
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 PLACEHOLDERS2) {
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
- 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
+ });
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", PLACEHOLDERS2, research;
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
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
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;