runcastle 1.0.20 → 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/index.js CHANGED
@@ -71,7 +71,7 @@ function resolveSandboxImage(config) {
71
71
  function resolveModel(step, config, project, runOverride) {
72
72
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
73
73
  }
74
- var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5-20251001", foldLegacyModelConfig = (raw) => {
74
+ var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5", foldLegacyModelConfig = (raw) => {
75
75
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
76
76
  return raw;
77
77
  const obj = { ...raw };
@@ -98,7 +98,7 @@ var init_config = __esm(() => {
98
98
  ModelStep = z.enum(MODEL_STEPS);
99
99
  RuncastleConfig = z.preprocess(foldLegacyModelConfig, z.object({
100
100
  serverPort: z.number().default(4512),
101
- model: z.string().default("claude-opus-4-8"),
101
+ model: z.string().default("claude-opus-5"),
102
102
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
103
103
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
104
104
  mainBranch: z.string().default("main"),
@@ -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(),
@@ -1126,6 +1132,27 @@ var init_errors = __esm(() => {
1126
1132
  };
1127
1133
  });
1128
1134
 
1135
+ // src/services/bus.ts
1136
+ function subscribeLive(fn) {
1137
+ subscribers.add(fn);
1138
+ return () => {
1139
+ subscribers.delete(fn);
1140
+ };
1141
+ }
1142
+ function publishLive(signal) {
1143
+ for (const fn of [...subscribers]) {
1144
+ try {
1145
+ fn(signal);
1146
+ } catch (err) {
1147
+ console.error("live bus subscriber threw", err);
1148
+ }
1149
+ }
1150
+ }
1151
+ var subscribers;
1152
+ var init_bus = __esm(() => {
1153
+ subscribers = new Set;
1154
+ });
1155
+
1129
1156
  // src/services/events.ts
1130
1157
  import { and, asc, eq, gt } from "drizzle-orm";
1131
1158
  function rowToEvent(row) {
@@ -1164,7 +1191,14 @@ function insertEvent(ctx, projectId, featureId, e) {
1164
1191
  message: e.message,
1165
1192
  data: e.data ?? null
1166
1193
  }).returning().get();
1167
- return rowToEvent(row);
1194
+ const event = rowToEvent(row);
1195
+ publishLive({
1196
+ kind: "event",
1197
+ projectId: event.projectId,
1198
+ featureId: event.featureId,
1199
+ eventId: event.id
1200
+ });
1201
+ return event;
1168
1202
  }
1169
1203
  function listAfter(ctx, featureId, afterId = 0) {
1170
1204
  const rows = ctx.db.select().from(events).where(and(eq(events.featureId, featureId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
@@ -1177,6 +1211,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1177
1211
  var init_events = __esm(() => {
1178
1212
  init_schema();
1179
1213
  init_errors();
1214
+ init_bus();
1180
1215
  });
1181
1216
 
1182
1217
  // src/services/repo.ts
@@ -1677,7 +1712,8 @@ function rowToTicket(row) {
1677
1712
  status: row.status,
1678
1713
  commits: row.commits,
1679
1714
  error: row.error ?? undefined,
1680
- attemptBranch: row.attemptBranch ?? undefined
1715
+ attemptBranch: row.attemptBranch ?? undefined,
1716
+ conflictFiles: row.conflictFiles ?? undefined
1681
1717
  };
1682
1718
  }
1683
1719
  function resolveBlocking(inputs, startSeq) {
@@ -1711,7 +1747,8 @@ function storeTickets(ctx, featureId, inputs) {
1711
1747
  status: "pending",
1712
1748
  commits: [],
1713
1749
  error: null,
1714
- attemptBranch: null
1750
+ attemptBranch: null,
1751
+ conflictFiles: null
1715
1752
  }));
1716
1753
  ctx.db.insert(tickets).values(rows).run();
1717
1754
  emit(ctx, featureId, {
@@ -1783,6 +1820,8 @@ function updateTicket(ctx, id, patch) {
1783
1820
  set.error = patch.error;
1784
1821
  if (patch.attemptBranch !== undefined)
1785
1822
  set.attemptBranch = patch.attemptBranch;
1823
+ if (patch.conflictFiles !== undefined)
1824
+ set.conflictFiles = patch.conflictFiles;
1786
1825
  ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1787
1826
  emit(ctx, current2.featureId, {
1788
1827
  type: "ticket.updated",
@@ -2169,6 +2208,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
2169
2208
  return [];
2170
2209
  }
2171
2210
  }
2211
+ async function commitSummaries(repoPath, base, branch, limit = 20) {
2212
+ try {
2213
+ const out = await git(repoPath).raw([
2214
+ "log",
2215
+ `--max-count=${limit}`,
2216
+ "--format=%h %s",
2217
+ `${base}..${branch}`
2218
+ ]);
2219
+ return out.split(`
2220
+ `).map((l) => l.trim()).filter(Boolean);
2221
+ } catch {
2222
+ return [];
2223
+ }
2224
+ }
2172
2225
  async function deleteTempBranch(repoPath, branch) {
2173
2226
  try {
2174
2227
  return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
@@ -2231,8 +2284,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2231
2284
  await gw.merge([tempBranch]);
2232
2285
  } catch (e) {
2233
2286
  if (await mergeInProgress(gw)) {
2287
+ const files = await conflictedFiles(gw);
2234
2288
  await gw.raw(["merge", "--abort"]);
2235
- return { ok: false, conflict: true, error: errMsg(e) };
2289
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2236
2290
  }
2237
2291
  return { ok: false, error: errMsg(e) };
2238
2292
  }
@@ -2263,8 +2317,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2263
2317
  await gw.merge([tempBranch]);
2264
2318
  } catch (e) {
2265
2319
  if (await mergeInProgress(gw)) {
2320
+ const files = await conflictedFiles(gw);
2266
2321
  await gw.raw(["merge", "--abort"]);
2267
- return { ok: false, conflict: true, error: errMsg(e) };
2322
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2268
2323
  }
2269
2324
  return { ok: false, error: errMsg(e) };
2270
2325
  }
@@ -2689,6 +2744,7 @@ var init_skills_root = __esm(() => {
2689
2744
  // src/services/agent-stream.ts
2690
2745
  function beginTranscript(ticketId) {
2691
2746
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2747
+ publishLive({ kind: "transcript", ticketId });
2692
2748
  }
2693
2749
  function appendTranscript(ticketId, chunk) {
2694
2750
  const t = transcripts.get(ticketId);
@@ -2704,11 +2760,13 @@ function appendTranscript(ticketId, chunk) {
2704
2760
  break;
2705
2761
  t.bytes -= dropped.text.length;
2706
2762
  }
2763
+ publishLive({ kind: "transcript", ticketId });
2707
2764
  }
2708
2765
  function endTranscript(ticketId) {
2709
2766
  const t = transcripts.get(ticketId);
2710
2767
  if (t)
2711
2768
  t.live = false;
2769
+ publishLive({ kind: "transcript", ticketId });
2712
2770
  }
2713
2771
  function readTranscript(ticketId, after = -1) {
2714
2772
  const t = transcripts.get(ticketId);
@@ -2723,6 +2781,7 @@ function readTranscript(ticketId, after = -1) {
2723
2781
  }
2724
2782
  var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2725
2783
  var init_agent_stream = __esm(() => {
2784
+ init_bus();
2726
2785
  transcripts = new Map;
2727
2786
  });
2728
2787
 
@@ -2777,13 +2836,16 @@ function detectCycle(tickets2) {
2777
2836
  }
2778
2837
  return null;
2779
2838
  }
2780
- function renderTicketPrompt(template, values) {
2839
+ function renderTemplate(template, values) {
2781
2840
  let out = template;
2782
- for (const key of PLACEHOLDERS) {
2783
- out = out.split(`{{${key}}}`).join(values[key]);
2841
+ for (const [key, value] of Object.entries(values)) {
2842
+ out = out.split(`{{${key}}}`).join(value);
2784
2843
  }
2785
2844
  return out;
2786
2845
  }
2846
+ function renderTicketPrompt(template, values) {
2847
+ return renderTemplate(template, values);
2848
+ }
2787
2849
  function buildTicketJson(ticket) {
2788
2850
  return JSON.stringify({
2789
2851
  seq: ticket.seq,
@@ -2817,6 +2879,23 @@ ${f.content.trim()}`).join(`
2817
2879
 
2818
2880
  `);
2819
2881
  }
2882
+ function buildConflictFilesBlock(files) {
2883
+ if (files.length === 0) {
2884
+ return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
2885
+ }
2886
+ return files.map((f) => `- \`${f}\``).join(`
2887
+ `);
2888
+ }
2889
+ function buildOtherSideBlock(summaries) {
2890
+ if (summaries.length === 0) {
2891
+ return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
2892
+ }
2893
+ return summaries.map((s) => `- ${s}`).join(`
2894
+ `);
2895
+ }
2896
+ function resolveMergeCommand(mode, featureBranch2) {
2897
+ return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
2898
+ }
2820
2899
  function parseEnvFile(content) {
2821
2900
  const out = {};
2822
2901
  for (const rawLine of content.split(/\r?\n/)) {
@@ -3041,6 +3120,42 @@ function createSerialQueue() {
3041
3120
  return next;
3042
3121
  };
3043
3122
  }
3123
+ async function landWithResolve(branch, deps) {
3124
+ let current2 = branch;
3125
+ for (let attempt = 1;; attempt++) {
3126
+ const merge = await deps.merge(current2);
3127
+ if (merge.ok)
3128
+ return { status: "landed", branch: current2 };
3129
+ const detail = merge.error ?? "merge failed";
3130
+ if (!merge.conflict)
3131
+ return { status: "failed", branch: current2, error: detail };
3132
+ const files = merge.files ?? [];
3133
+ if (attempt > deps.maxResolveAttempts) {
3134
+ return { status: "conflict", branch: current2, files, error: detail };
3135
+ }
3136
+ const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3137
+ deps.emit({
3138
+ type: "merge.conflict.resolving",
3139
+ message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3140
+ data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3141
+ });
3142
+ const resolved = await deps.resolve({
3143
+ branch: current2,
3144
+ files,
3145
+ attempt,
3146
+ maxAttempts: deps.maxResolveAttempts
3147
+ });
3148
+ current2 = resolved.branch;
3149
+ if (!resolved.ok) {
3150
+ return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3151
+ }
3152
+ deps.emit({
3153
+ type: "merge.conflict.resolved",
3154
+ message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3155
+ data: { branch: current2, files, attempt }
3156
+ });
3157
+ }
3158
+ }
3044
3159
  function errorHeadline(s) {
3045
3160
  const lines = s.split(`
3046
3161
  `).map((l) => l.trim()).filter(Boolean);
@@ -3188,8 +3303,14 @@ async function burnRun(ctx, deps) {
3188
3303
  return { status: done === total ? "succeeded" : "failed", summary };
3189
3304
  }
3190
3305
  function burnerTemplatePath() {
3306
+ return burnerAssetPath("implement-ticket.md");
3307
+ }
3308
+ function resolverTemplatePath() {
3309
+ return burnerAssetPath("resolve-conflict.md");
3310
+ }
3311
+ function burnerAssetPath(file) {
3191
3312
  const here = dirname3(fileURLToPath2(import.meta.url));
3192
- return join9(resolveSkillsRoot(here), "burner", "implement-ticket.md");
3313
+ return join9(resolveSkillsRoot(here), "burner", file);
3193
3314
  }
3194
3315
  function readDocsDigestFromDisk(projectId, slug) {
3195
3316
  const worktree = worktreeDir(projectId, slug);
@@ -3253,13 +3374,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3253
3374
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3254
3375
  if (workspaceMode === "isolated")
3255
3376
  await ensureIsolatedPushTarget();
3256
- const template = readFileSync4(burnerTemplatePath(), "utf8");
3257
- const basePrompt = renderTicketPrompt(template, {
3258
- TICKET_JSON: buildTicketJson(ticket),
3259
- FEATURE_BRIEF: buildFeatureBrief(feature),
3260
- DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
3377
+ const ticketJson = buildTicketJson(ticket);
3378
+ const featureBrief = buildFeatureBrief(feature);
3379
+ const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3380
+ const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3381
+ const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3382
+ TICKET_JSON: ticketJson,
3383
+ FEATURE_BRIEF: featureBrief,
3384
+ DOCS_DIGEST: docsDigest,
3261
3385
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3262
- WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
3386
+ WORKSPACE_NOTES: workspaceNotes
3263
3387
  });
3264
3388
  const toolchain = readRepoToolchain(project.repoPath);
3265
3389
  const pm = detectPackageManager(toolchain);
@@ -3292,9 +3416,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3292
3416
  activeTicketAborts.set(ticket.id, ticketAbort);
3293
3417
  const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
3294
3418
  const maxAttempts = Math.max(1, config2.burnAttempts);
3419
+ const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
3295
3420
  let baseBranch = feature.branch;
3296
3421
  let retryNotes;
3297
- if (ticket.attemptBranch) {
3422
+ if (ticket.attemptBranch && !conflictResume) {
3298
3423
  const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
3299
3424
  if (preserved.length > 0) {
3300
3425
  baseBranch = ticket.attemptBranch;
@@ -3313,7 +3438,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3313
3438
  const preserveChain = (branch) => {
3314
3439
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
3315
3440
  };
3441
+ const runResolver = async (input) => {
3442
+ const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3443
+ const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3444
+ const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3445
+ TICKET_JSON: ticketJson,
3446
+ FEATURE_BRIEF: featureBrief,
3447
+ DOCS_DIGEST: docsDigest,
3448
+ WORKSPACE_NOTES: workspaceNotes,
3449
+ FEATURE_BRANCH: feature.branch,
3450
+ CONFLICT_FILES: buildConflictFilesBlock(input.files),
3451
+ OTHER_SIDE: buildOtherSideBlock(otherSide),
3452
+ MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3453
+ });
3454
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3455
+ appendTranscript(ticket.id, {
3456
+ kind: "text",
3457
+ text: `
3458
+ \u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
3459
+ `
3460
+ });
3461
+ const bestTip = async () => {
3462
+ const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
3463
+ return made.length > 0 ? resolveBranch : input.branch;
3464
+ };
3465
+ let result;
3466
+ try {
3467
+ result = await run({
3468
+ agent: buildBurnAgent(config2, token, model),
3469
+ sandbox: selectSandbox(config2, mounts),
3470
+ cwd: project.repoPath,
3471
+ prompt,
3472
+ branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
3473
+ signal,
3474
+ name: `ticket-${ticket.seq}-resolve`,
3475
+ maxIterations: config2.burnMaxIterations,
3476
+ ...hookCommand ? {
3477
+ hooks: {
3478
+ sandbox: {
3479
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3480
+ }
3481
+ }
3482
+ } : {},
3483
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
3484
+ });
3485
+ } catch (err) {
3486
+ if (ctx.signal.aborted)
3487
+ throw err;
3488
+ return {
3489
+ ok: false,
3490
+ branch: await bestTip(),
3491
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3492
+ };
3493
+ }
3494
+ const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3495
+ if (blocked !== undefined && blocked.trim().length > 0) {
3496
+ return {
3497
+ ok: false,
3498
+ branch: await bestTip(),
3499
+ error: `resolver reported BLOCKED:
3500
+ ${blocked.trim()}`
3501
+ };
3502
+ }
3503
+ const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
3504
+ if (missing.length > 0) {
3505
+ return {
3506
+ ok: false,
3507
+ branch: await bestTip(),
3508
+ error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
3509
+ };
3510
+ }
3511
+ return { ok: true, branch: resolveBranch };
3512
+ };
3513
+ let landedCommits = [];
3514
+ const landDeps = {
3515
+ merge: (branch) => land(async () => {
3516
+ const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3517
+ const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
3518
+ if (res.ok)
3519
+ landedCommits = ahead;
3520
+ return res;
3521
+ }),
3522
+ resolve: runResolver,
3523
+ maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
3524
+ emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
3525
+ label: `ticket ${ticket.seq}`,
3526
+ featureBranch: feature.branch
3527
+ };
3528
+ const landChain = async (branch, commits) => {
3529
+ const landing = await landWithResolve(branch, landDeps);
3530
+ if (landing.status === "landed") {
3531
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3532
+ return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
3533
+ }
3534
+ preserveChain(landing.branch);
3535
+ if (landing.status === "conflict") {
3536
+ ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
3537
+ const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
3538
+ const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
3539
+ return {
3540
+ status: "failed",
3541
+ error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
3542
+ event: {
3543
+ type: "merge.conflict.needs-human",
3544
+ message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
3545
+ }
3546
+ };
3547
+ }
3548
+ return {
3549
+ status: "failed",
3550
+ 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`
3551
+ };
3552
+ };
3316
3553
  try {
3554
+ if (conflictResume) {
3555
+ const branch = ticket.attemptBranch;
3556
+ const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
3557
+ if (pending.length === 0) {
3558
+ ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
3559
+ ctx.emitEvent({
3560
+ type: "merge.conflict.resolved",
3561
+ message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
3562
+ ticketId: ticket.id,
3563
+ data: { branch }
3564
+ });
3565
+ return { status: "done", commits: ticket.commits };
3566
+ }
3567
+ ctx.emitEvent({
3568
+ type: "ticket.resuming",
3569
+ message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
3570
+ ticketId: ticket.id,
3571
+ data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
3572
+ });
3573
+ return await landChain(branch, pending);
3574
+ }
3317
3575
  let result;
3318
3576
  let tempBranch = "";
3319
3577
  for (let attempt = 1;; attempt++) {
@@ -3369,12 +3627,16 @@ ${retryNotes}` : basePrompt,
3369
3627
  };
3370
3628
  }
3371
3629
  if (isMergeConflictError(err)) {
3630
+ if (salvaged.length > 0) {
3631
+ preserveChain(tempBranch);
3632
+ ctx.updateTicket(ticket.id, { conflictFiles: [] });
3633
+ }
3372
3634
  return {
3373
3635
  status: "failed",
3374
3636
  error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3375
3637
  event: {
3376
3638
  type: "merge.conflict.needs-human",
3377
- message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3639
+ 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"}`
3378
3640
  }
3379
3641
  };
3380
3642
  }
@@ -3432,28 +3694,7 @@ ${blocked.trim()}` };
3432
3694
  preserveChain(tempBranch);
3433
3695
  return outcome;
3434
3696
  }
3435
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3436
- if (!merge.ok) {
3437
- const detail = merge.error ?? "merge failed";
3438
- if (merge.conflict) {
3439
- return {
3440
- status: "failed",
3441
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3442
- event: {
3443
- type: "merge.conflict.needs-human",
3444
- message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3445
- }
3446
- };
3447
- }
3448
- preserveChain(tempBranch);
3449
- return {
3450
- status: "failed",
3451
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3452
- };
3453
- }
3454
- if (ticket.attemptBranch)
3455
- ctx.updateTicket(ticket.id, { attemptBranch: null });
3456
- return outcome;
3697
+ return await landChain(tempBranch, outcome.commits);
3457
3698
  } finally {
3458
3699
  activeTicketAborts.delete(ticket.id);
3459
3700
  throttle.flush();
@@ -3486,7 +3727,7 @@ function readTokenFromEnvFile(path) {
3486
3727
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3487
3728
  return token && token.length > 0 ? token : undefined;
3488
3729
  }
3489
- 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;
3730
+ 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;
3490
3731
  var init_ticket_burner = __esm(() => {
3491
3732
  init_src();
3492
3733
  init_config_load();
@@ -3494,13 +3735,6 @@ var init_ticket_burner = __esm(() => {
3494
3735
  init_skills_root();
3495
3736
  init_agent_stream();
3496
3737
  init_git();
3497
- PLACEHOLDERS = [
3498
- "TICKET_JSON",
3499
- "FEATURE_BRIEF",
3500
- "DOCS_DIGEST",
3501
- "COMMIT_CONVENTION",
3502
- "WORKSPACE_NOTES"
3503
- ];
3504
3738
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3505
3739
  PM_CACHE_SANDBOX_PATHS = {
3506
3740
  bun: "~/.bun/install/cache",
@@ -3591,7 +3825,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
3591
3825
  }
3592
3826
  function renderResearchPrompt(template, values) {
3593
3827
  let out = template;
3594
- for (const key of PLACEHOLDERS2) {
3828
+ for (const key of PLACEHOLDERS) {
3595
3829
  out = out.split(`{{${key}}}`).join(values[key]);
3596
3830
  }
3597
3831
  return out;
@@ -3693,7 +3927,7 @@ function readTokenFromEnvFile2(path) {
3693
3927
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3694
3928
  return token && token.length > 0 ? token : undefined;
3695
3929
  }
3696
- var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
3930
+ var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
3697
3931
  var init_research = __esm(() => {
3698
3932
  init_src();
3699
3933
  init_config_load();
@@ -3701,7 +3935,7 @@ var init_research = __esm(() => {
3701
3935
  init_skills_root();
3702
3936
  init_git();
3703
3937
  init_ticket_burner();
3704
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3938
+ PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3705
3939
  research = {
3706
3940
  id: "research",
3707
3941
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4041,24 +4275,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
4041
4275
  preservedCommits = found.commits.length;
4042
4276
  }
4043
4277
  }
4278
+ const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
4044
4279
  for (const t of toReset.values()) {
4045
4280
  const isTarget = t.id === ticket.id;
4046
4281
  updateTicket(ctx, t.id, {
4047
4282
  status: "pending",
4048
4283
  error: null,
4049
- ...isTarget ? { attemptBranch: resumedFrom } : {}
4284
+ ...isTarget ? { attemptBranch: resumedFrom } : {},
4285
+ ...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
4050
4286
  });
4051
4287
  }
4052
4288
  const seqs = [...toReset.keys()].sort((a, b) => a - b);
4053
- const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
4289
+ 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}` : "";
4054
4290
  emit(ctx, feature.id, {
4055
4291
  type: "ticket.retry",
4056
4292
  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}`,
4057
4293
  ticketId: ticket.id,
4058
- data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
4294
+ data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
4059
4295
  });
4060
4296
  const { runId } = await burn(ctx, feature.id, { resetFailed: false });
4061
- return { runId, retried: seqs, resumedFrom, preservedCommits };
4297
+ return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
4062
4298
  }
4063
4299
  function escalateToMap(ctx, featureId, input) {
4064
4300
  const feature = getFeatureRow(ctx, featureId);
@@ -4635,6 +4871,57 @@ var init_hooks = __esm(() => {
4635
4871
  hooks_default = hooks;
4636
4872
  });
4637
4873
 
4874
+ // src/routes/stream.ts
4875
+ import { Hono as Hono3 } from "hono";
4876
+ import { streamSSE } from "hono/streaming";
4877
+ function dedupeKey(s) {
4878
+ return s.kind === "transcript" ? `transcript:${s.ticketId}` : "event";
4879
+ }
4880
+ var stream, FLUSH_MS = 120, HEARTBEAT_MS = 25000, stream_default;
4881
+ var init_stream = __esm(() => {
4882
+ init_bus();
4883
+ stream = new Hono3;
4884
+ stream.get("/", (c) => streamSSE(c, async (sse) => {
4885
+ const pending = new Map;
4886
+ let done = false;
4887
+ const stop = () => {
4888
+ done = true;
4889
+ };
4890
+ const unsubscribe = subscribeLive((signal) => {
4891
+ pending.set(dedupeKey(signal), signal);
4892
+ });
4893
+ sse.onAbort(stop);
4894
+ c.req.raw.signal.addEventListener("abort", stop);
4895
+ try {
4896
+ await sse.writeSSE({ event: "ready", data: "{}" });
4897
+ let idleMs = 0;
4898
+ while (!done && !sse.aborted && !sse.closed) {
4899
+ await sse.sleep(FLUSH_MS);
4900
+ if (done || sse.aborted || sse.closed)
4901
+ break;
4902
+ if (pending.size === 0) {
4903
+ idleMs += FLUSH_MS;
4904
+ if (idleMs >= HEARTBEAT_MS) {
4905
+ idleMs = 0;
4906
+ await sse.writeSSE({ event: "ping", data: "{}" });
4907
+ }
4908
+ continue;
4909
+ }
4910
+ idleMs = 0;
4911
+ const batch = [...pending.values()];
4912
+ pending.clear();
4913
+ for (const signal of batch) {
4914
+ await sse.writeSSE({ event: "live", data: JSON.stringify(signal) });
4915
+ }
4916
+ }
4917
+ } finally {
4918
+ unsubscribe();
4919
+ c.req.raw.signal.removeEventListener("abort", stop);
4920
+ }
4921
+ }));
4922
+ stream_default = stream;
4923
+ });
4924
+
4638
4925
  // src/routes/web.ts
4639
4926
  import { existsSync as existsSync12, statSync } from "fs";
4640
4927
  import { readFile } from "fs/promises";
@@ -5968,6 +6255,14 @@ var init_settings = __esm(() => {
5968
6255
  valueSchema: z9.number().int().min(1).max(5),
5969
6256
  parseEnv: (raw) => Number(raw)
5970
6257
  },
6258
+ {
6259
+ key: "burnConflictAttempts",
6260
+ configKey: "burnConflictAttempts",
6261
+ envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6262
+ restartRequired: false,
6263
+ valueSchema: z9.number().int().min(0).max(3),
6264
+ parseEnv: (raw) => Number(raw)
6265
+ },
5971
6266
  {
5972
6267
  key: "setupCommand",
5973
6268
  configKey: "setupCommand",
@@ -6609,9 +6904,9 @@ __export(exports_src, {
6609
6904
  buildApp: () => buildApp
6610
6905
  });
6611
6906
  import { trpcServer } from "@hono/trpc-server";
6612
- import { Hono as Hono3 } from "hono";
6907
+ import { Hono as Hono4 } from "hono";
6613
6908
  function buildApp(ctx) {
6614
- const app = new Hono3;
6909
+ const app = new Hono4;
6615
6910
  setRuntimeCtx(ctx);
6616
6911
  app.get("/health", (c) => c.json({ ok: true }));
6617
6912
  app.use("/api/trpc/*", async (c, next) => {
@@ -6625,6 +6920,7 @@ function buildApp(ctx) {
6625
6920
  createContext: () => ctx
6626
6921
  }));
6627
6922
  app.route("/api/hooks", hooks_default);
6923
+ app.route("/api/stream", stream_default);
6628
6924
  app.route("/mcp", server_default);
6629
6925
  mountWebAppIfBuilt(app);
6630
6926
  return app;
@@ -6677,6 +6973,7 @@ var init_src2 = __esm(() => {
6677
6973
  init_registry();
6678
6974
  init_ws();
6679
6975
  init_hooks();
6976
+ init_stream();
6680
6977
  init_web();
6681
6978
  init_router();
6682
6979
  init_reconcile_runs();