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/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
|
|
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
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
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
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
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}
|
|
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
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
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;
|