runcastle 1.0.21 → 1.0.22

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",
@@ -2199,6 +2209,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
2199
2209
  return [];
2200
2210
  }
2201
2211
  }
2212
+ async function commitSummaries(repoPath, base, branch, limit = 20) {
2213
+ try {
2214
+ const out = await git(repoPath).raw([
2215
+ "log",
2216
+ `--max-count=${limit}`,
2217
+ "--format=%h %s",
2218
+ `${base}..${branch}`
2219
+ ]);
2220
+ return out.split(`
2221
+ `).map((l) => l.trim()).filter(Boolean);
2222
+ } catch {
2223
+ return [];
2224
+ }
2225
+ }
2202
2226
  async function deleteTempBranch(repoPath, branch) {
2203
2227
  try {
2204
2228
  return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
@@ -2261,8 +2285,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2261
2285
  await gw.merge([tempBranch]);
2262
2286
  } catch (e) {
2263
2287
  if (await mergeInProgress(gw)) {
2288
+ const files = await conflictedFiles(gw);
2264
2289
  await gw.raw(["merge", "--abort"]);
2265
- return { ok: false, conflict: true, error: errMsg(e) };
2290
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2266
2291
  }
2267
2292
  return { ok: false, error: errMsg(e) };
2268
2293
  }
@@ -2293,8 +2318,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2293
2318
  await gw.merge([tempBranch]);
2294
2319
  } catch (e) {
2295
2320
  if (await mergeInProgress(gw)) {
2321
+ const files = await conflictedFiles(gw);
2296
2322
  await gw.raw(["merge", "--abort"]);
2297
- return { ok: false, conflict: true, error: errMsg(e) };
2323
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2298
2324
  }
2299
2325
  return { ok: false, error: errMsg(e) };
2300
2326
  }
@@ -2811,13 +2837,16 @@ function detectCycle(tickets2) {
2811
2837
  }
2812
2838
  return null;
2813
2839
  }
2814
- function renderTicketPrompt(template, values) {
2840
+ function renderTemplate(template, values) {
2815
2841
  let out = template;
2816
- for (const key of PLACEHOLDERS) {
2817
- out = out.split(`{{${key}}}`).join(values[key]);
2842
+ for (const [key, value] of Object.entries(values)) {
2843
+ out = out.split(`{{${key}}}`).join(value);
2818
2844
  }
2819
2845
  return out;
2820
2846
  }
2847
+ function renderTicketPrompt(template, values) {
2848
+ return renderTemplate(template, values);
2849
+ }
2821
2850
  function buildTicketJson(ticket) {
2822
2851
  return JSON.stringify({
2823
2852
  seq: ticket.seq,
@@ -2851,6 +2880,23 @@ ${f.content.trim()}`).join(`
2851
2880
 
2852
2881
  `);
2853
2882
  }
2883
+ function buildConflictFilesBlock(files) {
2884
+ if (files.length === 0) {
2885
+ return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
2886
+ }
2887
+ return files.map((f) => `- \`${f}\``).join(`
2888
+ `);
2889
+ }
2890
+ function buildOtherSideBlock(summaries) {
2891
+ if (summaries.length === 0) {
2892
+ return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
2893
+ }
2894
+ return summaries.map((s) => `- ${s}`).join(`
2895
+ `);
2896
+ }
2897
+ function resolveMergeCommand(mode, featureBranch2) {
2898
+ return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
2899
+ }
2854
2900
  function parseEnvFile(content) {
2855
2901
  const out = {};
2856
2902
  for (const rawLine of content.split(/\r?\n/)) {
@@ -3075,6 +3121,42 @@ function createSerialQueue() {
3075
3121
  return next;
3076
3122
  };
3077
3123
  }
3124
+ async function landWithResolve(branch, deps) {
3125
+ let current2 = branch;
3126
+ for (let attempt = 1;; attempt++) {
3127
+ const merge = await deps.merge(current2);
3128
+ if (merge.ok)
3129
+ return { status: "landed", branch: current2 };
3130
+ const detail = merge.error ?? "merge failed";
3131
+ if (!merge.conflict)
3132
+ return { status: "failed", branch: current2, error: detail };
3133
+ const files = merge.files ?? [];
3134
+ if (attempt > deps.maxResolveAttempts) {
3135
+ return { status: "conflict", branch: current2, files, error: detail };
3136
+ }
3137
+ const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3138
+ deps.emit({
3139
+ type: "merge.conflict.resolving",
3140
+ message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3141
+ data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3142
+ });
3143
+ const resolved = await deps.resolve({
3144
+ branch: current2,
3145
+ files,
3146
+ attempt,
3147
+ maxAttempts: deps.maxResolveAttempts
3148
+ });
3149
+ current2 = resolved.branch;
3150
+ if (!resolved.ok) {
3151
+ return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3152
+ }
3153
+ deps.emit({
3154
+ type: "merge.conflict.resolved",
3155
+ message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3156
+ data: { branch: current2, files, attempt }
3157
+ });
3158
+ }
3159
+ }
3078
3160
  function errorHeadline(s) {
3079
3161
  const lines = s.split(`
3080
3162
  `).map((l) => l.trim()).filter(Boolean);
@@ -3222,8 +3304,14 @@ async function burnRun(ctx, deps) {
3222
3304
  return { status: done === total ? "succeeded" : "failed", summary };
3223
3305
  }
3224
3306
  function burnerTemplatePath() {
3307
+ return burnerAssetPath("implement-ticket.md");
3308
+ }
3309
+ function resolverTemplatePath() {
3310
+ return burnerAssetPath("resolve-conflict.md");
3311
+ }
3312
+ function burnerAssetPath(file) {
3225
3313
  const here = dirname3(fileURLToPath2(import.meta.url));
3226
- return join9(resolveSkillsRoot(here), "burner", "implement-ticket.md");
3314
+ return join9(resolveSkillsRoot(here), "burner", file);
3227
3315
  }
3228
3316
  function readDocsDigestFromDisk(projectId, slug) {
3229
3317
  const worktree = worktreeDir(projectId, slug);
@@ -3287,13 +3375,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3287
3375
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3288
3376
  if (workspaceMode === "isolated")
3289
3377
  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),
3378
+ const ticketJson = buildTicketJson(ticket);
3379
+ const featureBrief = buildFeatureBrief(feature);
3380
+ const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3381
+ const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3382
+ const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3383
+ TICKET_JSON: ticketJson,
3384
+ FEATURE_BRIEF: featureBrief,
3385
+ DOCS_DIGEST: docsDigest,
3295
3386
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3296
- WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
3387
+ WORKSPACE_NOTES: workspaceNotes
3297
3388
  });
3298
3389
  const toolchain = readRepoToolchain(project.repoPath);
3299
3390
  const pm = detectPackageManager(toolchain);
@@ -3326,9 +3417,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3326
3417
  activeTicketAborts.set(ticket.id, ticketAbort);
3327
3418
  const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
3328
3419
  const maxAttempts = Math.max(1, config2.burnAttempts);
3420
+ const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
3329
3421
  let baseBranch = feature.branch;
3330
3422
  let retryNotes;
3331
- if (ticket.attemptBranch) {
3423
+ if (ticket.attemptBranch && !conflictResume) {
3332
3424
  const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
3333
3425
  if (preserved.length > 0) {
3334
3426
  baseBranch = ticket.attemptBranch;
@@ -3347,7 +3439,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3347
3439
  const preserveChain = (branch) => {
3348
3440
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
3349
3441
  };
3442
+ const runResolver = async (input) => {
3443
+ const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3444
+ const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3445
+ const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3446
+ TICKET_JSON: ticketJson,
3447
+ FEATURE_BRIEF: featureBrief,
3448
+ DOCS_DIGEST: docsDigest,
3449
+ WORKSPACE_NOTES: workspaceNotes,
3450
+ FEATURE_BRANCH: feature.branch,
3451
+ CONFLICT_FILES: buildConflictFilesBlock(input.files),
3452
+ OTHER_SIDE: buildOtherSideBlock(otherSide),
3453
+ MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3454
+ });
3455
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3456
+ appendTranscript(ticket.id, {
3457
+ kind: "text",
3458
+ text: `
3459
+ \u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
3460
+ `
3461
+ });
3462
+ const bestTip = async () => {
3463
+ const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
3464
+ return made.length > 0 ? resolveBranch : input.branch;
3465
+ };
3466
+ let result;
3467
+ try {
3468
+ result = await run({
3469
+ agent: buildBurnAgent(config2, token, model),
3470
+ sandbox: selectSandbox(config2, mounts),
3471
+ cwd: project.repoPath,
3472
+ prompt,
3473
+ branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
3474
+ signal,
3475
+ name: `ticket-${ticket.seq}-resolve`,
3476
+ maxIterations: config2.burnMaxIterations,
3477
+ ...hookCommand ? {
3478
+ hooks: {
3479
+ sandbox: {
3480
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3481
+ }
3482
+ }
3483
+ } : {},
3484
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
3485
+ });
3486
+ } catch (err) {
3487
+ if (ctx.signal.aborted)
3488
+ throw err;
3489
+ return {
3490
+ ok: false,
3491
+ branch: await bestTip(),
3492
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3493
+ };
3494
+ }
3495
+ const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3496
+ if (blocked !== undefined && blocked.trim().length > 0) {
3497
+ return {
3498
+ ok: false,
3499
+ branch: await bestTip(),
3500
+ error: `resolver reported BLOCKED:
3501
+ ${blocked.trim()}`
3502
+ };
3503
+ }
3504
+ const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
3505
+ if (missing.length > 0) {
3506
+ return {
3507
+ ok: false,
3508
+ branch: await bestTip(),
3509
+ error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
3510
+ };
3511
+ }
3512
+ return { ok: true, branch: resolveBranch };
3513
+ };
3514
+ let landedCommits = [];
3515
+ const landDeps = {
3516
+ merge: (branch) => land(async () => {
3517
+ const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3518
+ const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
3519
+ if (res.ok)
3520
+ landedCommits = ahead;
3521
+ return res;
3522
+ }),
3523
+ resolve: runResolver,
3524
+ maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
3525
+ emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
3526
+ label: `ticket ${ticket.seq}`,
3527
+ featureBranch: feature.branch
3528
+ };
3529
+ const landChain = async (branch, commits) => {
3530
+ const landing = await landWithResolve(branch, landDeps);
3531
+ if (landing.status === "landed") {
3532
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3533
+ return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
3534
+ }
3535
+ preserveChain(landing.branch);
3536
+ if (landing.status === "conflict") {
3537
+ ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
3538
+ const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
3539
+ const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
3540
+ return {
3541
+ status: "failed",
3542
+ error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
3543
+ event: {
3544
+ type: "merge.conflict.needs-human",
3545
+ message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
3546
+ }
3547
+ };
3548
+ }
3549
+ return {
3550
+ status: "failed",
3551
+ 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`
3552
+ };
3553
+ };
3350
3554
  try {
3555
+ if (conflictResume) {
3556
+ const branch = ticket.attemptBranch;
3557
+ const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3558
+ if (pending.length === 0) {
3559
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3560
+ ctx.emitEvent({
3561
+ type: "merge.conflict.resolved",
3562
+ message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
3563
+ ticketId: ticket.id,
3564
+ data: { branch }
3565
+ });
3566
+ return { status: "done", commits: ticket.commits };
3567
+ }
3568
+ ctx.emitEvent({
3569
+ type: "ticket.resuming",
3570
+ message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
3571
+ ticketId: ticket.id,
3572
+ data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
3573
+ });
3574
+ return await landChain(branch, pending);
3575
+ }
3351
3576
  let result;
3352
3577
  let tempBranch = "";
3353
3578
  for (let attempt = 1;; attempt++) {
@@ -3403,12 +3628,16 @@ ${retryNotes}` : basePrompt,
3403
3628
  };
3404
3629
  }
3405
3630
  if (isMergeConflictError(err)) {
3631
+ if (salvaged.length > 0) {
3632
+ preserveChain(tempBranch);
3633
+ ctx.updateTicket(ticket.id, { conflictFiles: [] });
3634
+ }
3406
3635
  return {
3407
3636
  status: "failed",
3408
3637
  error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3409
3638
  event: {
3410
3639
  type: "merge.conflict.needs-human",
3411
- message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3640
+ 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
3641
  }
3413
3642
  };
3414
3643
  }
@@ -3466,28 +3695,7 @@ ${blocked.trim()}` };
3466
3695
  preserveChain(tempBranch);
3467
3696
  return outcome;
3468
3697
  }
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;
3698
+ return await landChain(tempBranch, outcome.commits);
3491
3699
  } finally {
3492
3700
  activeTicketAborts.delete(ticket.id);
3493
3701
  throttle.flush();
@@ -3520,7 +3728,7 @@ function readTokenFromEnvFile(path) {
3520
3728
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3521
3729
  return token && token.length > 0 ? token : undefined;
3522
3730
  }
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;
3731
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3524
3732
  var init_ticket_burner = __esm(() => {
3525
3733
  init_src();
3526
3734
  init_config_load();
@@ -3528,13 +3736,6 @@ var init_ticket_burner = __esm(() => {
3528
3736
  init_skills_root();
3529
3737
  init_agent_stream();
3530
3738
  init_git();
3531
- PLACEHOLDERS = [
3532
- "TICKET_JSON",
3533
- "FEATURE_BRIEF",
3534
- "DOCS_DIGEST",
3535
- "COMMIT_CONVENTION",
3536
- "WORKSPACE_NOTES"
3537
- ];
3538
3739
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3539
3740
  PM_CACHE_SANDBOX_PATHS = {
3540
3741
  bun: "~/.bun/install/cache",
@@ -3625,7 +3826,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
3625
3826
  }
3626
3827
  function renderResearchPrompt(template, values) {
3627
3828
  let out = template;
3628
- for (const key of PLACEHOLDERS2) {
3829
+ for (const key of PLACEHOLDERS) {
3629
3830
  out = out.split(`{{${key}}}`).join(values[key]);
3630
3831
  }
3631
3832
  return out;
@@ -3727,7 +3928,7 @@ function readTokenFromEnvFile2(path) {
3727
3928
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3728
3929
  return token && token.length > 0 ? token : undefined;
3729
3930
  }
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;
3931
+ 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
3932
  var init_research = __esm(() => {
3732
3933
  init_src();
3733
3934
  init_config_load();
@@ -3735,7 +3936,7 @@ var init_research = __esm(() => {
3735
3936
  init_skills_root();
3736
3937
  init_git();
3737
3938
  init_ticket_burner();
3738
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3939
+ PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3739
3940
  research = {
3740
3941
  id: "research",
3741
3942
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4075,24 +4276,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
4075
4276
  preservedCommits = found.commits.length;
4076
4277
  }
4077
4278
  }
4279
+ const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
4078
4280
  for (const t of toReset.values()) {
4079
4281
  const isTarget = t.id === ticket.id;
4080
4282
  updateTicket(ctx, t.id, {
4081
4283
  status: "pending",
4082
4284
  error: null,
4083
- ...isTarget ? { attemptBranch: resumedFrom } : {}
4285
+ ...isTarget ? { attemptBranch: resumedFrom } : {},
4286
+ ...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
4084
4287
  });
4085
4288
  }
4086
4289
  const seqs = [...toReset.keys()].sort((a, b) => a - b);
4087
- const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
4290
+ 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
4291
  emit(ctx, feature.id, {
4089
4292
  type: "ticket.retry",
4090
4293
  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
4294
  ticketId: ticket.id,
4092
- data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
4295
+ data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
4093
4296
  });
4094
4297
  const { runId } = await burn(ctx, feature.id, { resetFailed: false });
4095
- return { runId, retried: seqs, resumedFrom, preservedCommits };
4298
+ return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
4096
4299
  }
4097
4300
  function escalateToMap(ctx, featureId, input) {
4098
4301
  const feature = getFeatureRow(ctx, featureId);
@@ -6053,6 +6256,14 @@ var init_settings = __esm(() => {
6053
6256
  valueSchema: z9.number().int().min(1).max(5),
6054
6257
  parseEnv: (raw) => Number(raw)
6055
6258
  },
6259
+ {
6260
+ key: "burnConflictAttempts",
6261
+ configKey: "burnConflictAttempts",
6262
+ envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6263
+ restartRequired: false,
6264
+ valueSchema: z9.number().int().min(0).max(3),
6265
+ parseEnv: (raw) => Number(raw)
6266
+ },
6056
6267
  {
6057
6268
  key: "setupCommand",
6058
6269
  configKey: "setupCommand",
@@ -0,0 +1 @@
1
+ ALTER TABLE `tickets` ADD `conflict_files` text;