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/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 renderTicketPrompt(template, values) {
2867
+ function renderTemplate(template, values) {
2814
2868
  let out = template;
2815
- for (const key of PLACEHOLDERS) {
2816
- out = out.split(`{{${key}}}`).join(values[key]);
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", "implement-ticket.md");
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 template = readFileSync4(burnerTemplatePath(), "utf8");
3290
- const basePrompt = renderTicketPrompt(template, {
3291
- TICKET_JSON: buildTicketJson(ticket),
3292
- FEATURE_BRIEF: buildFeatureBrief(feature),
3293
- DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
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: buildWorkspaceNotes(workspaceMode)
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
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
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", 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;
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 PLACEHOLDERS2) {
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
- return { status: "failed", error: err instanceof Error ? err.message : String(err) };
3947
+ const msg = err instanceof Error ? err.message : String(err);
3948
+ salvaged = isWorktreeTeardownError(err) ? await branchCommitsAhead(project.repoPath, feature.branch, tempBranch) : [];
3949
+ if (salvaged.length === 0)
3950
+ return { status: "failed", error: msg };
3951
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3952
+ ctx.emitEvent({
3953
+ type: "burn.worktree.teardown-failed",
3954
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3955
+ data: { tempBranch, error: msg, cleanedUp: removed }
3956
+ });
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", PLACEHOLDERS2, research;
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
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",