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/bin/runcastle.js CHANGED
@@ -72,7 +72,7 @@ function resolveSandboxImage(config) {
72
72
  function resolveModel(step, config, project, runOverride) {
73
73
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
74
74
  }
75
- var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5-20251001", foldLegacyModelConfig = (raw) => {
75
+ var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5", foldLegacyModelConfig = (raw) => {
76
76
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
77
77
  return raw;
78
78
  const obj = { ...raw };
@@ -99,7 +99,7 @@ var init_config = __esm(() => {
99
99
  ModelStep = z.enum(MODEL_STEPS);
100
100
  RuncastleConfig = z.preprocess(foldLegacyModelConfig, z.object({
101
101
  serverPort: z.number().default(4512),
102
- model: z.string().default("claude-opus-4-8"),
102
+ model: z.string().default("claude-opus-5"),
103
103
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
104
104
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
105
105
  mainBranch: z.string().default("main"),
@@ -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(),
@@ -1127,6 +1133,27 @@ var init_errors = __esm(() => {
1127
1133
  };
1128
1134
  });
1129
1135
 
1136
+ // src/services/bus.ts
1137
+ function subscribeLive(fn) {
1138
+ subscribers.add(fn);
1139
+ return () => {
1140
+ subscribers.delete(fn);
1141
+ };
1142
+ }
1143
+ function publishLive(signal) {
1144
+ for (const fn of [...subscribers]) {
1145
+ try {
1146
+ fn(signal);
1147
+ } catch (err) {
1148
+ console.error("live bus subscriber threw", err);
1149
+ }
1150
+ }
1151
+ }
1152
+ var subscribers;
1153
+ var init_bus = __esm(() => {
1154
+ subscribers = new Set;
1155
+ });
1156
+
1130
1157
  // src/services/events.ts
1131
1158
  import { and, asc, eq, gt } from "drizzle-orm";
1132
1159
  function rowToEvent(row) {
@@ -1165,7 +1192,14 @@ function insertEvent(ctx, projectId, featureId, e) {
1165
1192
  message: e.message,
1166
1193
  data: e.data ?? null
1167
1194
  }).returning().get();
1168
- return rowToEvent(row);
1195
+ const event = rowToEvent(row);
1196
+ publishLive({
1197
+ kind: "event",
1198
+ projectId: event.projectId,
1199
+ featureId: event.featureId,
1200
+ eventId: event.id
1201
+ });
1202
+ return event;
1169
1203
  }
1170
1204
  function listAfter(ctx, featureId, afterId = 0) {
1171
1205
  const rows = ctx.db.select().from(events).where(and(eq(events.featureId, featureId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
@@ -1178,6 +1212,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1178
1212
  var init_events = __esm(() => {
1179
1213
  init_schema();
1180
1214
  init_errors();
1215
+ init_bus();
1181
1216
  });
1182
1217
 
1183
1218
  // src/services/repo.ts
@@ -1678,7 +1713,8 @@ function rowToTicket(row) {
1678
1713
  status: row.status,
1679
1714
  commits: row.commits,
1680
1715
  error: row.error ?? undefined,
1681
- attemptBranch: row.attemptBranch ?? undefined
1716
+ attemptBranch: row.attemptBranch ?? undefined,
1717
+ conflictFiles: row.conflictFiles ?? undefined
1682
1718
  };
1683
1719
  }
1684
1720
  function resolveBlocking(inputs, startSeq) {
@@ -1712,7 +1748,8 @@ function storeTickets(ctx, featureId, inputs) {
1712
1748
  status: "pending",
1713
1749
  commits: [],
1714
1750
  error: null,
1715
- attemptBranch: null
1751
+ attemptBranch: null,
1752
+ conflictFiles: null
1716
1753
  }));
1717
1754
  ctx.db.insert(tickets).values(rows).run();
1718
1755
  emit(ctx, featureId, {
@@ -1784,6 +1821,8 @@ function updateTicket(ctx, id, patch) {
1784
1821
  set.error = patch.error;
1785
1822
  if (patch.attemptBranch !== undefined)
1786
1823
  set.attemptBranch = patch.attemptBranch;
1824
+ if (patch.conflictFiles !== undefined)
1825
+ set.conflictFiles = patch.conflictFiles;
1787
1826
  ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1788
1827
  emit(ctx, current2.featureId, {
1789
1828
  type: "ticket.updated",
@@ -2170,6 +2209,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
2170
2209
  return [];
2171
2210
  }
2172
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
+ }
2173
2226
  async function deleteTempBranch(repoPath, branch) {
2174
2227
  try {
2175
2228
  return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
@@ -2232,8 +2285,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2232
2285
  await gw.merge([tempBranch]);
2233
2286
  } catch (e) {
2234
2287
  if (await mergeInProgress(gw)) {
2288
+ const files = await conflictedFiles(gw);
2235
2289
  await gw.raw(["merge", "--abort"]);
2236
- return { ok: false, conflict: true, error: errMsg(e) };
2290
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2237
2291
  }
2238
2292
  return { ok: false, error: errMsg(e) };
2239
2293
  }
@@ -2264,8 +2318,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2264
2318
  await gw.merge([tempBranch]);
2265
2319
  } catch (e) {
2266
2320
  if (await mergeInProgress(gw)) {
2321
+ const files = await conflictedFiles(gw);
2267
2322
  await gw.raw(["merge", "--abort"]);
2268
- return { ok: false, conflict: true, error: errMsg(e) };
2323
+ return { ok: false, conflict: true, files, error: errMsg(e) };
2269
2324
  }
2270
2325
  return { ok: false, error: errMsg(e) };
2271
2326
  }
@@ -2690,6 +2745,7 @@ var init_skills_root = __esm(() => {
2690
2745
  // src/services/agent-stream.ts
2691
2746
  function beginTranscript(ticketId) {
2692
2747
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2748
+ publishLive({ kind: "transcript", ticketId });
2693
2749
  }
2694
2750
  function appendTranscript(ticketId, chunk) {
2695
2751
  const t = transcripts.get(ticketId);
@@ -2705,11 +2761,13 @@ function appendTranscript(ticketId, chunk) {
2705
2761
  break;
2706
2762
  t.bytes -= dropped.text.length;
2707
2763
  }
2764
+ publishLive({ kind: "transcript", ticketId });
2708
2765
  }
2709
2766
  function endTranscript(ticketId) {
2710
2767
  const t = transcripts.get(ticketId);
2711
2768
  if (t)
2712
2769
  t.live = false;
2770
+ publishLive({ kind: "transcript", ticketId });
2713
2771
  }
2714
2772
  function readTranscript(ticketId, after = -1) {
2715
2773
  const t = transcripts.get(ticketId);
@@ -2724,6 +2782,7 @@ function readTranscript(ticketId, after = -1) {
2724
2782
  }
2725
2783
  var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2726
2784
  var init_agent_stream = __esm(() => {
2785
+ init_bus();
2727
2786
  transcripts = new Map;
2728
2787
  });
2729
2788
 
@@ -2778,13 +2837,16 @@ function detectCycle(tickets2) {
2778
2837
  }
2779
2838
  return null;
2780
2839
  }
2781
- function renderTicketPrompt(template, values) {
2840
+ function renderTemplate(template, values) {
2782
2841
  let out = template;
2783
- for (const key of PLACEHOLDERS) {
2784
- out = out.split(`{{${key}}}`).join(values[key]);
2842
+ for (const [key, value] of Object.entries(values)) {
2843
+ out = out.split(`{{${key}}}`).join(value);
2785
2844
  }
2786
2845
  return out;
2787
2846
  }
2847
+ function renderTicketPrompt(template, values) {
2848
+ return renderTemplate(template, values);
2849
+ }
2788
2850
  function buildTicketJson(ticket) {
2789
2851
  return JSON.stringify({
2790
2852
  seq: ticket.seq,
@@ -2818,6 +2880,23 @@ ${f.content.trim()}`).join(`
2818
2880
 
2819
2881
  `);
2820
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
+ }
2821
2900
  function parseEnvFile(content) {
2822
2901
  const out = {};
2823
2902
  for (const rawLine of content.split(/\r?\n/)) {
@@ -3042,6 +3121,42 @@ function createSerialQueue() {
3042
3121
  return next;
3043
3122
  };
3044
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
+ }
3045
3160
  function errorHeadline(s) {
3046
3161
  const lines = s.split(`
3047
3162
  `).map((l) => l.trim()).filter(Boolean);
@@ -3189,8 +3304,14 @@ async function burnRun(ctx, deps) {
3189
3304
  return { status: done === total ? "succeeded" : "failed", summary };
3190
3305
  }
3191
3306
  function burnerTemplatePath() {
3307
+ return burnerAssetPath("implement-ticket.md");
3308
+ }
3309
+ function resolverTemplatePath() {
3310
+ return burnerAssetPath("resolve-conflict.md");
3311
+ }
3312
+ function burnerAssetPath(file) {
3192
3313
  const here = dirname3(fileURLToPath2(import.meta.url));
3193
- return join9(resolveSkillsRoot(here), "burner", "implement-ticket.md");
3314
+ return join9(resolveSkillsRoot(here), "burner", file);
3194
3315
  }
3195
3316
  function readDocsDigestFromDisk(projectId, slug) {
3196
3317
  const worktree = worktreeDir(projectId, slug);
@@ -3254,13 +3375,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3254
3375
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3255
3376
  if (workspaceMode === "isolated")
3256
3377
  await ensureIsolatedPushTarget();
3257
- const template = readFileSync4(burnerTemplatePath(), "utf8");
3258
- const basePrompt = renderTicketPrompt(template, {
3259
- TICKET_JSON: buildTicketJson(ticket),
3260
- FEATURE_BRIEF: buildFeatureBrief(feature),
3261
- 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,
3262
3386
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3263
- WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
3387
+ WORKSPACE_NOTES: workspaceNotes
3264
3388
  });
3265
3389
  const toolchain = readRepoToolchain(project.repoPath);
3266
3390
  const pm = detectPackageManager(toolchain);
@@ -3293,9 +3417,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3293
3417
  activeTicketAborts.set(ticket.id, ticketAbort);
3294
3418
  const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
3295
3419
  const maxAttempts = Math.max(1, config2.burnAttempts);
3420
+ const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
3296
3421
  let baseBranch = feature.branch;
3297
3422
  let retryNotes;
3298
- if (ticket.attemptBranch) {
3423
+ if (ticket.attemptBranch && !conflictResume) {
3299
3424
  const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
3300
3425
  if (preserved.length > 0) {
3301
3426
  baseBranch = ticket.attemptBranch;
@@ -3314,7 +3439,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3314
3439
  const preserveChain = (branch) => {
3315
3440
  ctx.updateTicket(ticket.id, { attemptBranch: branch });
3316
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
+ };
3317
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
+ }
3318
3576
  let result;
3319
3577
  let tempBranch = "";
3320
3578
  for (let attempt = 1;; attempt++) {
@@ -3370,12 +3628,16 @@ ${retryNotes}` : basePrompt,
3370
3628
  };
3371
3629
  }
3372
3630
  if (isMergeConflictError(err)) {
3631
+ if (salvaged.length > 0) {
3632
+ preserveChain(tempBranch);
3633
+ ctx.updateTicket(ticket.id, { conflictFiles: [] });
3634
+ }
3373
3635
  return {
3374
3636
  status: "failed",
3375
3637
  error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3376
3638
  event: {
3377
3639
  type: "merge.conflict.needs-human",
3378
- 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"}`
3379
3641
  }
3380
3642
  };
3381
3643
  }
@@ -3433,28 +3695,7 @@ ${blocked.trim()}` };
3433
3695
  preserveChain(tempBranch);
3434
3696
  return outcome;
3435
3697
  }
3436
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3437
- if (!merge.ok) {
3438
- const detail = merge.error ?? "merge failed";
3439
- if (merge.conflict) {
3440
- return {
3441
- status: "failed",
3442
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3443
- event: {
3444
- type: "merge.conflict.needs-human",
3445
- message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3446
- }
3447
- };
3448
- }
3449
- preserveChain(tempBranch);
3450
- return {
3451
- status: "failed",
3452
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3453
- };
3454
- }
3455
- if (ticket.attemptBranch)
3456
- ctx.updateTicket(ticket.id, { attemptBranch: null });
3457
- return outcome;
3698
+ return await landChain(tempBranch, outcome.commits);
3458
3699
  } finally {
3459
3700
  activeTicketAborts.delete(ticket.id);
3460
3701
  throttle.flush();
@@ -3487,7 +3728,7 @@ function readTokenFromEnvFile(path) {
3487
3728
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3488
3729
  return token && token.length > 0 ? token : undefined;
3489
3730
  }
3490
- 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;
3491
3732
  var init_ticket_burner = __esm(() => {
3492
3733
  init_src();
3493
3734
  init_config_load();
@@ -3495,13 +3736,6 @@ var init_ticket_burner = __esm(() => {
3495
3736
  init_skills_root();
3496
3737
  init_agent_stream();
3497
3738
  init_git();
3498
- PLACEHOLDERS = [
3499
- "TICKET_JSON",
3500
- "FEATURE_BRIEF",
3501
- "DOCS_DIGEST",
3502
- "COMMIT_CONVENTION",
3503
- "WORKSPACE_NOTES"
3504
- ];
3505
3739
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3506
3740
  PM_CACHE_SANDBOX_PATHS = {
3507
3741
  bun: "~/.bun/install/cache",
@@ -3592,7 +3826,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
3592
3826
  }
3593
3827
  function renderResearchPrompt(template, values) {
3594
3828
  let out = template;
3595
- for (const key of PLACEHOLDERS2) {
3829
+ for (const key of PLACEHOLDERS) {
3596
3830
  out = out.split(`{{${key}}}`).join(values[key]);
3597
3831
  }
3598
3832
  return out;
@@ -3694,7 +3928,7 @@ function readTokenFromEnvFile2(path) {
3694
3928
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3695
3929
  return token && token.length > 0 ? token : undefined;
3696
3930
  }
3697
- 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;
3698
3932
  var init_research = __esm(() => {
3699
3933
  init_src();
3700
3934
  init_config_load();
@@ -3702,7 +3936,7 @@ var init_research = __esm(() => {
3702
3936
  init_skills_root();
3703
3937
  init_git();
3704
3938
  init_ticket_burner();
3705
- PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3939
+ PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
3706
3940
  research = {
3707
3941
  id: "research",
3708
3942
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4042,24 +4276,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
4042
4276
  preservedCommits = found.commits.length;
4043
4277
  }
4044
4278
  }
4279
+ const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
4045
4280
  for (const t of toReset.values()) {
4046
4281
  const isTarget = t.id === ticket.id;
4047
4282
  updateTicket(ctx, t.id, {
4048
4283
  status: "pending",
4049
4284
  error: null,
4050
- ...isTarget ? { attemptBranch: resumedFrom } : {}
4285
+ ...isTarget ? { attemptBranch: resumedFrom } : {},
4286
+ ...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
4051
4287
  });
4052
4288
  }
4053
4289
  const seqs = [...toReset.keys()].sort((a, b) => a - b);
4054
- 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}` : "";
4055
4291
  emit(ctx, feature.id, {
4056
4292
  type: "ticket.retry",
4057
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}`,
4058
4294
  ticketId: ticket.id,
4059
- data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
4295
+ data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
4060
4296
  });
4061
4297
  const { runId } = await burn(ctx, feature.id, { resetFailed: false });
4062
- return { runId, retried: seqs, resumedFrom, preservedCommits };
4298
+ return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
4063
4299
  }
4064
4300
  function escalateToMap(ctx, featureId, input) {
4065
4301
  const feature = getFeatureRow(ctx, featureId);
@@ -4636,6 +4872,57 @@ var init_hooks = __esm(() => {
4636
4872
  hooks_default = hooks;
4637
4873
  });
4638
4874
 
4875
+ // src/routes/stream.ts
4876
+ import { Hono as Hono3 } from "hono";
4877
+ import { streamSSE } from "hono/streaming";
4878
+ function dedupeKey(s) {
4879
+ return s.kind === "transcript" ? `transcript:${s.ticketId}` : "event";
4880
+ }
4881
+ var stream, FLUSH_MS = 120, HEARTBEAT_MS = 25000, stream_default;
4882
+ var init_stream = __esm(() => {
4883
+ init_bus();
4884
+ stream = new Hono3;
4885
+ stream.get("/", (c) => streamSSE(c, async (sse) => {
4886
+ const pending = new Map;
4887
+ let done = false;
4888
+ const stop = () => {
4889
+ done = true;
4890
+ };
4891
+ const unsubscribe = subscribeLive((signal) => {
4892
+ pending.set(dedupeKey(signal), signal);
4893
+ });
4894
+ sse.onAbort(stop);
4895
+ c.req.raw.signal.addEventListener("abort", stop);
4896
+ try {
4897
+ await sse.writeSSE({ event: "ready", data: "{}" });
4898
+ let idleMs = 0;
4899
+ while (!done && !sse.aborted && !sse.closed) {
4900
+ await sse.sleep(FLUSH_MS);
4901
+ if (done || sse.aborted || sse.closed)
4902
+ break;
4903
+ if (pending.size === 0) {
4904
+ idleMs += FLUSH_MS;
4905
+ if (idleMs >= HEARTBEAT_MS) {
4906
+ idleMs = 0;
4907
+ await sse.writeSSE({ event: "ping", data: "{}" });
4908
+ }
4909
+ continue;
4910
+ }
4911
+ idleMs = 0;
4912
+ const batch = [...pending.values()];
4913
+ pending.clear();
4914
+ for (const signal of batch) {
4915
+ await sse.writeSSE({ event: "live", data: JSON.stringify(signal) });
4916
+ }
4917
+ }
4918
+ } finally {
4919
+ unsubscribe();
4920
+ c.req.raw.signal.removeEventListener("abort", stop);
4921
+ }
4922
+ }));
4923
+ stream_default = stream;
4924
+ });
4925
+
4639
4926
  // src/routes/web.ts
4640
4927
  import { existsSync as existsSync12, statSync } from "fs";
4641
4928
  import { readFile } from "fs/promises";
@@ -5969,6 +6256,14 @@ var init_settings = __esm(() => {
5969
6256
  valueSchema: z9.number().int().min(1).max(5),
5970
6257
  parseEnv: (raw) => Number(raw)
5971
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
+ },
5972
6267
  {
5973
6268
  key: "setupCommand",
5974
6269
  configKey: "setupCommand",
@@ -6610,9 +6905,9 @@ __export(exports_src, {
6610
6905
  buildApp: () => buildApp
6611
6906
  });
6612
6907
  import { trpcServer } from "@hono/trpc-server";
6613
- import { Hono as Hono3 } from "hono";
6908
+ import { Hono as Hono4 } from "hono";
6614
6909
  function buildApp(ctx) {
6615
- const app = new Hono3;
6910
+ const app = new Hono4;
6616
6911
  setRuntimeCtx(ctx);
6617
6912
  app.get("/health", (c) => c.json({ ok: true }));
6618
6913
  app.use("/api/trpc/*", async (c, next) => {
@@ -6626,6 +6921,7 @@ function buildApp(ctx) {
6626
6921
  createContext: () => ctx
6627
6922
  }));
6628
6923
  app.route("/api/hooks", hooks_default);
6924
+ app.route("/api/stream", stream_default);
6629
6925
  app.route("/mcp", server_default);
6630
6926
  mountWebAppIfBuilt(app);
6631
6927
  return app;
@@ -6678,6 +6974,7 @@ var init_src2 = __esm(() => {
6678
6974
  init_registry();
6679
6975
  init_ws();
6680
6976
  init_hooks();
6977
+ init_stream();
6681
6978
  init_web();
6682
6979
  init_router();
6683
6980
  init_reconcile_runs();