runcastle 1.0.13 → 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
  }
@@ -2839,6 +2896,55 @@ function isMergeConflictError(err) {
2839
2896
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
2840
2897
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
2841
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
+ }
2842
2948
  function createSerialQueue() {
2843
2949
  let tail = Promise.resolve();
2844
2950
  return (task) => {
@@ -3058,12 +3164,11 @@ function selectSandbox(config2, mounts = []) {
3058
3164
  }
3059
3165
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3060
3166
  const { project, feature } = ctx;
3061
- const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3062
3167
  const workspaceMode = resolveBurnWorkspaceMode(config2);
3063
3168
  if (workspaceMode === "isolated")
3064
3169
  await ensureIsolatedPushTarget();
3065
3170
  const template = readFileSync4(burnerTemplatePath(), "utf8");
3066
- const prompt = renderTicketPrompt(template, {
3171
+ const basePrompt = renderTicketPrompt(template, {
3067
3172
  TICKET_JSON: buildTicketJson(ticket),
3068
3173
  FEATURE_BRIEF: buildFeatureBrief(feature),
3069
3174
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
@@ -3081,14 +3186,6 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3081
3186
  mounts.push(mount);
3082
3187
  }
3083
3188
  }
3084
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3085
- if (hookCommand) {
3086
- ctx.emitEvent({
3087
- type: "burn.setup",
3088
- message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
3089
- ticketId: ticket.id
3090
- });
3091
- }
3092
3189
  mkdirSync4(logsDir(), { recursive: true });
3093
3190
  const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3094
3191
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
@@ -3105,70 +3202,177 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3105
3202
  });
3106
3203
  }
3107
3204
  };
3108
- const runOptions = {
3109
- agent: buildBurnAgent(config2, token, model),
3110
- sandbox: selectSandbox(config2, mounts),
3111
- cwd: project.repoPath,
3112
- prompt,
3113
- branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
3114
- signal: ctx.signal,
3115
- name: `ticket-${ticket.seq}`,
3116
- maxIterations: config2.burnMaxIterations,
3117
- ...hookCommand ? {
3118
- hooks: {
3119
- sandbox: {
3120
- onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3121
- }
3122
- }
3123
- } : {},
3124
- 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 });
3125
3229
  };
3126
- let result;
3127
3230
  try {
3128
- result = await run(runOptions);
3129
- } catch (err) {
3130
- throttle.flush();
3131
- endTranscript(ticket.id);
3132
- if (ctx.signal.aborted)
3133
- throw err;
3134
- const msg = err instanceof Error ? err.message : String(err);
3135
- if (isMergeConflictError(err)) {
3136
- return {
3137
- status: "failed",
3138
- error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
3139
- event: {
3140
- type: "merge.conflict.needs-human",
3141
- message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
3142
- }
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 }
3143
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
+ }
3144
3330
  }
3145
- return { status: "failed", error: msg };
3146
- }
3147
- throttle.flush();
3148
- endTranscript(ticket.id);
3149
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3150
- const outcome = interpretRunResult(result, blocked);
3151
- if (outcome.status !== "done")
3152
- return outcome;
3153
- const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
3154
- if (!merge.ok) {
3155
- const detail = merge.error ?? "merge failed";
3156
- 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);
3157
3363
  return {
3158
3364
  status: "failed",
3159
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
3160
- event: {
3161
- type: "merge.conflict.needs-human",
3162
- message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
3163
- }
3365
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3164
3366
  };
3165
3367
  }
3166
- return {
3167
- status: "failed",
3168
- error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
3169
- };
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);
3170
3375
  }
3171
- return outcome;
3172
3376
  }
3173
3377
  function resolveBurnDeps(ctx) {
3174
3378
  const config2 = loadConfig();
@@ -3196,7 +3400,7 @@ function readTokenFromEnvFile(path) {
3196
3400
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3197
3401
  return token && token.length > 0 ? token : undefined;
3198
3402
  }
3199
- 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;
3200
3404
  var init_ticket_burner = __esm(() => {
3201
3405
  init_src();
3202
3406
  init_config_load();
@@ -3217,6 +3421,25 @@ var init_ticket_burner = __esm(() => {
3217
3421
  yarn: "~/.cache/yarn",
3218
3422
  npm: "~/.npm"
3219
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;
3220
3443
  SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
3221
3444
  ticketBurner = {
3222
3445
  id: "ticket-burner",
@@ -3660,7 +3883,7 @@ async function burn(ctx, featureId, opts = {}) {
3660
3883
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
3661
3884
  }
3662
3885
  if (restarting) {
3663
- const failed = tickets2.filter((t) => t.status === "failed");
3886
+ const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
3664
3887
  for (const t of failed) {
3665
3888
  updateTicket(ctx, t.id, { status: "pending", error: null });
3666
3889
  }
@@ -3677,6 +3900,69 @@ async function burn(ctx, featureId, opts = {}) {
3677
3900
  });
3678
3901
  return { runId };
3679
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
+ }
3680
3966
  function escalateToMap(ctx, featureId, input) {
3681
3967
  const feature = getFeatureRow(ctx, featureId);
3682
3968
  if (feature.mapped) {
@@ -5359,6 +5645,14 @@ var init_settings = __esm(() => {
5359
5645
  valueSchema: z9.number().int().min(1).max(10),
5360
5646
  parseEnv: (raw) => Number(raw)
5361
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
+ },
5362
5656
  {
5363
5657
  key: "setupCommand",
5364
5658
  configKey: "setupCommand",
@@ -5904,6 +6198,21 @@ var init_system = __esm(() => {
5904
6198
  });
5905
6199
  });
5906
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
+
5907
6216
  // src/trpc/router.ts
5908
6217
  var appRouter;
5909
6218
  var init_router = __esm(() => {
@@ -5916,10 +6225,12 @@ var init_router = __esm(() => {
5916
6225
  init_settings2();
5917
6226
  init_setup2();
5918
6227
  init_system();
6228
+ init_ticket();
5919
6229
  appRouter = router({
5920
6230
  project: projectRouter,
5921
6231
  feature: featureRouter,
5922
6232
  run: runRouter,
6233
+ ticket: ticketRouter,
5923
6234
  events: eventsRouter,
5924
6235
  docs: docsRouter,
5925
6236
  settings: settingsRouter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",