runcastle 1.0.12 → 1.0.14

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
@@ -106,6 +106,7 @@ var init_config = __esm(() => {
106
106
  sandboxImage: z.string().optional(),
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
+ burnAttempts: z.number().int().min(1).max(5).default(3),
109
110
  setupCommand: z.string().optional(),
110
111
  burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
111
112
  }));
@@ -146,6 +147,9 @@ function loadConfig(env = process.env) {
146
147
  if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
147
148
  overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
148
149
  }
150
+ if (env.RUNCASTLE_BURN_ATTEMPTS) {
151
+ overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
152
+ }
149
153
  if (env.RUNCASTLE_SETUP_COMMAND)
150
154
  overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
151
155
  if (env.RUNCASTLE_BURN_WORKSPACE)
@@ -212,7 +216,8 @@ var init_schemas = __esm(() => {
212
216
  seq: z2.number(),
213
217
  status: TicketStatus,
214
218
  commits: z2.array(z2.string()),
215
- error: z2.string().optional()
219
+ error: z2.string().optional(),
220
+ attemptBranch: z2.string().optional()
216
221
  });
217
222
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
218
223
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -491,7 +496,8 @@ var init_db_schema = __esm(() => {
491
496
  blockedBy: text("blocked_by", { mode: "json" }).notNull().$type(),
492
497
  status: text("status").notNull().$type(),
493
498
  commits: text("commits", { mode: "json" }).notNull().$type(),
494
- error: text("error")
499
+ error: text("error"),
500
+ attemptBranch: text("attempt_branch")
495
501
  });
496
502
  waypoints = sqliteTable("waypoints", {
497
503
  id: text("id").primaryKey(),
@@ -1658,7 +1664,8 @@ function rowToTicket(row) {
1658
1664
  blockedBy: row.blockedBy,
1659
1665
  status: row.status,
1660
1666
  commits: row.commits,
1661
- error: row.error ?? undefined
1667
+ error: row.error ?? undefined,
1668
+ attemptBranch: row.attemptBranch ?? undefined
1662
1669
  };
1663
1670
  }
1664
1671
  function resolveBlocking(inputs, startSeq) {
@@ -1691,7 +1698,8 @@ function storeTickets(ctx, featureId, inputs) {
1691
1698
  blockedBy: resolved[i].blockedBy,
1692
1699
  status: "pending",
1693
1700
  commits: [],
1694
- error: null
1701
+ error: null,
1702
+ attemptBranch: null
1695
1703
  }));
1696
1704
  ctx.db.insert(tickets).values(rows).run();
1697
1705
  emit(ctx, featureId, {
@@ -1761,6 +1769,8 @@ function updateTicket(ctx, id, patch) {
1761
1769
  set.commits = patch.commits;
1762
1770
  if (patch.error !== undefined)
1763
1771
  set.error = patch.error;
1772
+ if (patch.attemptBranch !== undefined)
1773
+ set.attemptBranch = patch.attemptBranch;
1764
1774
  ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1765
1775
  emit(ctx, current2.featureId, {
1766
1776
  type: "ticket.updated",
@@ -2140,6 +2150,53 @@ function researchBranchName(slug, waypointSeq, unique) {
2140
2150
  function ticketBranchName(slug, ticketSeq, unique) {
2141
2151
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2142
2152
  }
2153
+ async function branchCommitsAhead(repoPath, base, branch) {
2154
+ try {
2155
+ const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
2156
+ return out.split(`
2157
+ `).map((l) => l.trim()).filter(Boolean);
2158
+ } catch {
2159
+ return [];
2160
+ }
2161
+ }
2162
+ async function deleteTempBranch(repoPath, branch) {
2163
+ try {
2164
+ return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
2165
+ } catch {
2166
+ return false;
2167
+ }
2168
+ }
2169
+ function ticketBranchPrefix(slug, ticketSeq) {
2170
+ return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-`;
2171
+ }
2172
+ async function listTicketAttemptBranches(repoPath, slug, ticketSeq) {
2173
+ const prefix = ticketBranchPrefix(slug, ticketSeq);
2174
+ try {
2175
+ return (await git(repoPath).branchLocal()).all.filter((b) => b.startsWith(prefix));
2176
+ } catch {
2177
+ return [];
2178
+ }
2179
+ }
2180
+ async function findPreservedTicketBranch(repoPath, featureBranch2, slug, ticketSeq) {
2181
+ try {
2182
+ const g = git(repoPath);
2183
+ let best;
2184
+ for (const branch of await listTicketAttemptBranches(repoPath, slug, ticketSeq)) {
2185
+ const commits = await branchCommitsAhead(repoPath, featureBranch2, branch);
2186
+ if (commits.length === 0)
2187
+ continue;
2188
+ let tipTime = 0;
2189
+ try {
2190
+ tipTime = Number((await g.raw(["log", "-1", "--format=%ct", branch])).trim()) || 0;
2191
+ } catch {}
2192
+ if (!best || tipTime > best.tipTime)
2193
+ best = { branch, commits, tipTime };
2194
+ }
2195
+ return best ? { branch: best.branch, commits: best.commits } : undefined;
2196
+ } catch {
2197
+ return;
2198
+ }
2199
+ }
2143
2200
  async function allowPushToCheckedOutBranches(repoPath) {
2144
2201
  await git(repoPath).addConfig("receive.denyCurrentBranch", "ignore");
2145
2202
  }
@@ -2740,7 +2797,7 @@ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
2740
2797
  return platform === "linux" ? "mounted" : "isolated";
2741
2798
  return config2.burnWorkspace;
2742
2799
  }
2743
- function buildIsolatedSetupCommand(tempBranch, setupCommand) {
2800
+ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
2744
2801
  const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
2745
2802
  const parts = [
2746
2803
  `git config --global --add safe.directory '*'`,
@@ -2748,8 +2805,13 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand) {
2748
2805
  `printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exec git -C ${SANDBOX_WORKSPACE_PATH} reset --hard --quiet %s\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
2749
2806
  `chmod +x ${hookFile}`
2750
2807
  ];
2808
+ if (pm === "pnpm" || pm === "yarn") {
2809
+ const shim = `$HOME/.local/bin/${pm}`;
2810
+ parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
2811
+ }
2751
2812
  if (setupCommand)
2752
2813
  parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
2814
+ parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
2753
2815
  return parts.join(" && ");
2754
2816
  }
2755
2817
  function buildWorkspaceNotes(mode) {
@@ -2759,7 +2821,7 @@ function buildWorkspaceNotes(mode) {
2759
2821
  return [
2760
2822
  `Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
2761
2823
  "",
2762
- `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal.`,
2824
+ `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
2763
2825
  "",
2764
2826
  `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`
2765
2827
  ].join(`
@@ -2835,6 +2897,55 @@ function isMergeConflictError(err) {
2835
2897
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
2836
2898
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
2837
2899
  }
2900
+ function classifyTicketRunError(err) {
2901
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
2902
+ if (FATAL_ERROR_PATTERNS.some((p) => p.test(msg)))
2903
+ return "fatal";
2904
+ if (RETRYABLE_ERROR_PATTERNS.some((p) => p.test(msg)))
2905
+ return "retryable";
2906
+ return "fatal";
2907
+ }
2908
+ function retryDelayMs(attempt) {
2909
+ return Math.min(30000, 5000 * 2 ** (attempt - 1));
2910
+ }
2911
+ function buildRetryNotes(input) {
2912
+ const cause = input.error ? ` (${input.error})` : "";
2913
+ const commits = input.commitCount > 0 ? `${input.commitCount} commit(s) from the previous attempt(s) are already on your branch \u2014 completed work, not noise.` : "The previous attempt had not committed anything yet, so you are effectively starting clean.";
2914
+ return [
2915
+ "## Recovery context \u2014 a previous attempt was interrupted",
2916
+ "",
2917
+ `A previous agent working THIS SAME ticket was killed by a transient infrastructure error${cause} \u2014 not by anything it did wrong, and not by a human decision. You are picking up where it left off.`,
2918
+ "",
2919
+ commits,
2920
+ "",
2921
+ "Before doing anything else, run `git log --oneline -15` and `git status` to see what was already completed. Build on that work \u2014 do NOT revert or redo existing commits. Uncommitted changes from the previous attempt were lost; only commits survived. If the ticket turns out to be fully implemented already, verify the acceptance criteria and finish normally."
2922
+ ].join(`
2923
+ `);
2924
+ }
2925
+ function delayUnlessAborted(ms, signal) {
2926
+ return new Promise((resolve6) => {
2927
+ if (signal.aborted) {
2928
+ resolve6();
2929
+ return;
2930
+ }
2931
+ const onAbort = () => {
2932
+ clearTimeout(t);
2933
+ resolve6();
2934
+ };
2935
+ const t = setTimeout(() => {
2936
+ signal.removeEventListener("abort", onAbort);
2937
+ resolve6();
2938
+ }, ms);
2939
+ signal.addEventListener("abort", onAbort, { once: true });
2940
+ });
2941
+ }
2942
+ function stopTicketRun(ticketId) {
2943
+ const controller = activeTicketAborts.get(ticketId);
2944
+ if (!controller)
2945
+ return false;
2946
+ controller.abort(new Error("ticket stopped by user"));
2947
+ return true;
2948
+ }
2838
2949
  function createSerialQueue() {
2839
2950
  let tail = Promise.resolve();
2840
2951
  return (task) => {
@@ -3054,12 +3165,11 @@ function selectSandbox(config2, mounts = []) {
3054
3165
  }
3055
3166
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3056
3167
  const { project, feature } = ctx;
3057
- const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3058
3168
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3059
3169
  if (workspaceMode === "isolated")
3060
3170
  await ensureIsolatedPushTarget();
3061
3171
  const template = readFileSync4(burnerTemplatePath(), "utf8");
3062
- const prompt = renderTicketPrompt(template, {
3172
+ const basePrompt = renderTicketPrompt(template, {
3063
3173
  TICKET_JSON: buildTicketJson(ticket),
3064
3174
  FEATURE_BRIEF: buildFeatureBrief(feature),
3065
3175
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
@@ -3077,14 +3187,6 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3077
3187
  mounts.push(mount);
3078
3188
  }
3079
3189
  }
3080
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand) : setupCommand;
3081
- if (hookCommand) {
3082
- ctx.emitEvent({
3083
- type: "burn.setup",
3084
- message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
3085
- ticketId: ticket.id
3086
- });
3087
- }
3088
3190
  mkdirSync4(logsDir(), { recursive: true });
3089
3191
  const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3090
3192
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
@@ -3101,70 +3203,177 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3101
3203
  });
3102
3204
  }
3103
3205
  };
3104
- const runOptions = {
3105
- agent: buildBurnAgent(config2, token, model),
3106
- sandbox: selectSandbox(config2, mounts),
3107
- cwd: project.repoPath,
3108
- prompt,
3109
- branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
3110
- signal: ctx.signal,
3111
- name: `ticket-${ticket.seq}`,
3112
- maxIterations: config2.burnMaxIterations,
3113
- ...hookCommand ? {
3114
- hooks: {
3115
- sandbox: {
3116
- onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3117
- }
3118
- }
3119
- } : {},
3120
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
3206
+ const ticketAbort = new AbortController;
3207
+ activeTicketAborts.set(ticket.id, ticketAbort);
3208
+ const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
3209
+ const maxAttempts = Math.max(1, config2.burnAttempts);
3210
+ let baseBranch = feature.branch;
3211
+ let retryNotes;
3212
+ if (ticket.attemptBranch) {
3213
+ const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
3214
+ if (preserved.length > 0) {
3215
+ baseBranch = ticket.attemptBranch;
3216
+ retryNotes = buildRetryNotes({
3217
+ ...ticket.error ? { error: errorHeadline(ticket.error) } : {},
3218
+ commitCount: preserved.length
3219
+ });
3220
+ ctx.emitEvent({
3221
+ type: "ticket.resuming",
3222
+ message: `ticket ${ticket.seq}: resuming from ${preserved.length} preserved commit(s) of a previous attempt`,
3223
+ ticketId: ticket.id,
3224
+ data: { attemptBranch: ticket.attemptBranch, preservedCommits: preserved.length }
3225
+ });
3226
+ }
3227
+ }
3228
+ const preserveChain = (branch) => {
3229
+ ctx.updateTicket(ticket.id, { attemptBranch: branch });
3121
3230
  };
3122
- let result;
3123
3231
  try {
3124
- result = await run(runOptions);
3125
- } catch (err) {
3126
- throttle.flush();
3127
- endTranscript(ticket.id);
3128
- if (ctx.signal.aborted)
3129
- throw err;
3130
- const msg = err instanceof Error ? err.message : String(err);
3131
- if (isMergeConflictError(err)) {
3132
- return {
3133
- status: "failed",
3134
- error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3135
- event: {
3136
- type: "merge.conflict.needs-human",
3137
- message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3138
- }
3232
+ let result;
3233
+ let tempBranch = "";
3234
+ for (let attempt = 1;; attempt++) {
3235
+ tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3236
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3237
+ if (hookCommand && attempt === 1) {
3238
+ ctx.emitEvent({
3239
+ type: "burn.setup",
3240
+ message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
3241
+ ticketId: ticket.id
3242
+ });
3243
+ }
3244
+ const runOptions = {
3245
+ agent: buildBurnAgent(config2, token, model),
3246
+ sandbox: selectSandbox(config2, mounts),
3247
+ cwd: project.repoPath,
3248
+ prompt: retryNotes ? `${basePrompt}
3249
+
3250
+ ${retryNotes}` : basePrompt,
3251
+ branchStrategy: { type: "branch", branch: tempBranch, baseBranch },
3252
+ signal,
3253
+ name: `ticket-${ticket.seq}`,
3254
+ maxIterations: config2.burnMaxIterations,
3255
+ ...hookCommand ? {
3256
+ hooks: {
3257
+ sandbox: {
3258
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3259
+ }
3260
+ }
3261
+ } : {},
3262
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
3139
3263
  };
3264
+ try {
3265
+ result = await run(runOptions);
3266
+ break;
3267
+ } catch (err) {
3268
+ if (ctx.signal.aborted)
3269
+ throw err;
3270
+ const msg = err instanceof Error ? err.message : String(err);
3271
+ const salvaged = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
3272
+ if (salvaged.length > 0)
3273
+ baseBranch = tempBranch;
3274
+ if (ticketAbort.signal.aborted) {
3275
+ if (salvaged.length > 0)
3276
+ preserveChain(tempBranch);
3277
+ return {
3278
+ status: "failed",
3279
+ error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
3280
+ event: {
3281
+ type: "ticket.stopped",
3282
+ message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
3283
+ }
3284
+ };
3285
+ }
3286
+ if (isMergeConflictError(err)) {
3287
+ return {
3288
+ status: "failed",
3289
+ error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3290
+ event: {
3291
+ type: "merge.conflict.needs-human",
3292
+ message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3293
+ }
3294
+ };
3295
+ }
3296
+ if (classifyTicketRunError(err) === "retryable" && attempt < maxAttempts) {
3297
+ const headline = errorHeadline(msg);
3298
+ retryNotes = buildRetryNotes({ error: headline, commitCount: salvaged.length });
3299
+ ctx.emitEvent({
3300
+ type: "ticket.retrying",
3301
+ message: `ticket ${ticket.seq} attempt ${attempt}/${maxAttempts} died (${headline}) \u2014 retrying${salvaged.length > 0 ? ` from ${salvaged.length} preserved commit(s)` : ""}`,
3302
+ ticketId: ticket.id,
3303
+ data: { attempt, maxAttempts, error: msg, preservedCommits: salvaged.length }
3304
+ });
3305
+ appendTranscript(ticket.id, {
3306
+ kind: "text",
3307
+ text: `
3308
+ \u2014 attempt ${attempt} interrupted (${headline}); starting attempt ${attempt + 1}${salvaged.length > 0 ? " with committed work preserved" : ""} \u2014
3309
+ `
3310
+ });
3311
+ await delayUnlessAborted(retryDelayMs(attempt), signal);
3312
+ ctx.signal.throwIfAborted();
3313
+ if (ticketAbort.signal.aborted) {
3314
+ if (salvaged.length > 0)
3315
+ preserveChain(tempBranch);
3316
+ return {
3317
+ status: "failed",
3318
+ error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
3319
+ event: {
3320
+ type: "ticket.stopped",
3321
+ message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
3322
+ }
3323
+ };
3324
+ }
3325
+ continue;
3326
+ }
3327
+ if (salvaged.length > 0)
3328
+ preserveChain(tempBranch);
3329
+ return { status: "failed", error: msg };
3330
+ }
3140
3331
  }
3141
- return { status: "failed", error: msg };
3142
- }
3143
- throttle.flush();
3144
- endTranscript(ticket.id);
3145
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3146
- const outcome = interpretRunResult(result, blocked);
3147
- if (outcome.status !== "done")
3148
- return outcome;
3149
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3150
- if (!merge.ok) {
3151
- const detail = merge.error ?? "merge failed";
3152
- if (merge.conflict) {
3332
+ if (!result)
3333
+ return { status: "failed", error: "internal: run loop produced no result" };
3334
+ const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3335
+ const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
3336
+ let outcome;
3337
+ if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
3338
+ outcome = { status: "failed", error: `agent reported BLOCKED:
3339
+ ${blocked.trim()}` };
3340
+ } else if (chain.length > 0) {
3341
+ outcome = { status: "done", commits: chain };
3342
+ } else {
3343
+ outcome = interpretRunResult(result, blocked);
3344
+ }
3345
+ if (outcome.status !== "done") {
3346
+ if (chain.length > 0)
3347
+ preserveChain(tempBranch);
3348
+ return outcome;
3349
+ }
3350
+ const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3351
+ if (!merge.ok) {
3352
+ const detail = merge.error ?? "merge failed";
3353
+ if (merge.conflict) {
3354
+ return {
3355
+ status: "failed",
3356
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3357
+ event: {
3358
+ type: "merge.conflict.needs-human",
3359
+ message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3360
+ }
3361
+ };
3362
+ }
3363
+ preserveChain(tempBranch);
3153
3364
  return {
3154
3365
  status: "failed",
3155
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3156
- event: {
3157
- type: "merge.conflict.needs-human",
3158
- message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3159
- }
3366
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3160
3367
  };
3161
3368
  }
3162
- return {
3163
- status: "failed",
3164
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3165
- };
3369
+ if (ticket.attemptBranch)
3370
+ ctx.updateTicket(ticket.id, { attemptBranch: null });
3371
+ return outcome;
3372
+ } finally {
3373
+ activeTicketAborts.delete(ticket.id);
3374
+ throttle.flush();
3375
+ endTranscript(ticket.id);
3166
3376
  }
3167
- return outcome;
3168
3377
  }
3169
3378
  function resolveBurnDeps(ctx) {
3170
3379
  const config2 = loadConfig();
@@ -3192,7 +3401,7 @@ function readTokenFromEnvFile(path) {
3192
3401
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3193
3402
  return token && token.length > 0 ? token : undefined;
3194
3403
  }
3195
- 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", SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3404
+ 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;
3196
3405
  var init_ticket_burner = __esm(() => {
3197
3406
  init_src();
3198
3407
  init_config_load();
@@ -3213,6 +3422,25 @@ var init_ticket_burner = __esm(() => {
3213
3422
  yarn: "~/.cache/yarn",
3214
3423
  npm: "~/.npm"
3215
3424
  };
3425
+ FATAL_ERROR_PATTERNS = [
3426
+ /invalid (api key|x-api-key)/i,
3427
+ /authentication|unauthorized|permission denied/i,
3428
+ /credit balance|billing/i,
3429
+ /oauth token|setup-token/i,
3430
+ /issue with the selected model|model not found|unknown model/i,
3431
+ /does not support resumeSession|resumeSession .* not found/i
3432
+ ];
3433
+ RETRYABLE_ERROR_PATTERNS = [
3434
+ /exited with code \d+/i,
3435
+ /idle timeout|AgentIdleTimeout/i,
3436
+ /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
3437
+ /econnreset|etimedout|econnrefused|epipe|eai_again/i,
3438
+ /overloaded|rate.?limit|too many requests/i,
3439
+ /internal server error|service unavailable|bad gateway|gateway timeout/i,
3440
+ /\bapi error\b/i,
3441
+ /session capture failed/i
3442
+ ];
3443
+ activeTicketAborts = new Map;
3216
3444
  SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
3217
3445
  ticketBurner = {
3218
3446
  id: "ticket-burner",
@@ -3656,7 +3884,7 @@ async function burn(ctx, featureId, opts = {}) {
3656
3884
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
3657
3885
  }
3658
3886
  if (restarting) {
3659
- const failed = tickets2.filter((t) => t.status === "failed");
3887
+ const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
3660
3888
  for (const t of failed) {
3661
3889
  updateTicket(ctx, t.id, { status: "pending", error: null });
3662
3890
  }
@@ -3673,6 +3901,69 @@ async function burn(ctx, featureId, opts = {}) {
3673
3901
  });
3674
3902
  return { runId };
3675
3903
  }
3904
+ async function retryTicket(ctx, ticketId, opts = {}) {
3905
+ const ticket = getTicket(ctx, ticketId);
3906
+ const feature = getFeatureRow(ctx, ticket.featureId);
3907
+ if (ticket.status !== "failed") {
3908
+ throw new GateError(`only failed tickets can be retried \u2014 ticket ${ticket.seq} is ${ticket.status}`);
3909
+ }
3910
+ if (hasActiveRun(ctx, feature.id)) {
3911
+ throw new GateError("a run is live for this feature \u2014 retry after it finishes, or cancel it first");
3912
+ }
3913
+ const project = projectForFeature(ctx, feature);
3914
+ const all = listByFeature2(ctx, feature.id);
3915
+ const bySeq = new Map(all.map((t) => [t.seq, t]));
3916
+ const toReset = new Map([[ticket.seq, ticket]]);
3917
+ const queue = [ticket.seq];
3918
+ while (queue.length > 0) {
3919
+ const t = bySeq.get(queue.shift());
3920
+ if (!t)
3921
+ continue;
3922
+ for (const b of t.blockedBy) {
3923
+ const blocker = bySeq.get(b);
3924
+ if (blocker && blocker.status === "failed" && !toReset.has(blocker.seq)) {
3925
+ toReset.set(blocker.seq, blocker);
3926
+ queue.push(blocker.seq);
3927
+ }
3928
+ }
3929
+ }
3930
+ let resumedFrom = ticket.attemptBranch ?? null;
3931
+ let preservedCommits = 0;
3932
+ if (opts.fresh) {
3933
+ const discard = new Set(await listTicketAttemptBranches(project.repoPath, feature.slug, ticket.seq));
3934
+ if (ticket.attemptBranch)
3935
+ discard.add(ticket.attemptBranch);
3936
+ for (const b of discard)
3937
+ await deleteTempBranch(project.repoPath, b);
3938
+ resumedFrom = null;
3939
+ } else if (resumedFrom) {
3940
+ preservedCommits = (await branchCommitsAhead(project.repoPath, feature.branch, resumedFrom)).length;
3941
+ } else {
3942
+ const found = await findPreservedTicketBranch(project.repoPath, feature.branch, feature.slug, ticket.seq);
3943
+ if (found) {
3944
+ resumedFrom = found.branch;
3945
+ preservedCommits = found.commits.length;
3946
+ }
3947
+ }
3948
+ for (const t of toReset.values()) {
3949
+ const isTarget = t.id === ticket.id;
3950
+ updateTicket(ctx, t.id, {
3951
+ status: "pending",
3952
+ error: null,
3953
+ ...isTarget ? { attemptBranch: resumedFrom } : {}
3954
+ });
3955
+ }
3956
+ const seqs = [...toReset.keys()].sort((a, b) => a - b);
3957
+ const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
3958
+ emit(ctx, feature.id, {
3959
+ type: "ticket.retry",
3960
+ 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}`,
3961
+ ticketId: ticket.id,
3962
+ data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
3963
+ });
3964
+ const { runId } = await burn(ctx, feature.id, { resetFailed: false });
3965
+ return { runId, retried: seqs, resumedFrom, preservedCommits };
3966
+ }
3676
3967
  function escalateToMap(ctx, featureId, input) {
3677
3968
  const feature = getFeatureRow(ctx, featureId);
3678
3969
  if (feature.mapped) {
@@ -5355,6 +5646,14 @@ var init_settings = __esm(() => {
5355
5646
  valueSchema: z9.number().int().min(1).max(10),
5356
5647
  parseEnv: (raw) => Number(raw)
5357
5648
  },
5649
+ {
5650
+ key: "burnAttempts",
5651
+ configKey: "burnAttempts",
5652
+ envVar: "RUNCASTLE_BURN_ATTEMPTS",
5653
+ restartRequired: false,
5654
+ valueSchema: z9.number().int().min(1).max(5),
5655
+ parseEnv: (raw) => Number(raw)
5656
+ },
5358
5657
  {
5359
5658
  key: "setupCommand",
5360
5659
  configKey: "setupCommand",
@@ -5900,6 +6199,21 @@ var init_system = __esm(() => {
5900
6199
  });
5901
6200
  });
5902
6201
 
6202
+ // src/trpc/routers/ticket.ts
6203
+ import * as z12 from "zod";
6204
+ var ticketRouter;
6205
+ var init_ticket = __esm(() => {
6206
+ init_features();
6207
+ init_tickets();
6208
+ init_ticket_burner();
6209
+ init_context();
6210
+ ticketRouter = router({
6211
+ retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
6212
+ stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ input }) => ({ stopped: stopTicketRun(input.ticketId) })),
6213
+ cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
6214
+ });
6215
+ });
6216
+
5903
6217
  // src/trpc/router.ts
5904
6218
  var appRouter;
5905
6219
  var init_router = __esm(() => {
@@ -5912,10 +6226,12 @@ var init_router = __esm(() => {
5912
6226
  init_settings2();
5913
6227
  init_setup2();
5914
6228
  init_system();
6229
+ init_ticket();
5915
6230
  appRouter = router({
5916
6231
  project: projectRouter,
5917
6232
  feature: featureRouter,
5918
6233
  run: runRouter,
6234
+ ticket: ticketRouter,
5919
6235
  events: eventsRouter,
5920
6236
  docs: docsRouter,
5921
6237
  settings: settingsRouter,
@@ -0,0 +1 @@
1
+ ALTER TABLE `tickets` ADD `attempt_branch` text;