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 +382 -71
- package/drizzle/0007_amused_apocalypse.sql +1 -0
- package/drizzle/meta/0007_snapshot.json +610 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +382 -71
- package/package.json +1 -1
- package/web/assets/{index-B0l9lJT5.js → index-BEeyZA5j.js} +3 -2
- package/web/assets/index-CI8-AEi9.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-JQYIwCj3.css +0 -1
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
|
|
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
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
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
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
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
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
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}
|
|
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
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
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,
|