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