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