runcastle 1.0.12 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/runcastle.js +389 -73
- 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 +389 -73
- 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
|
}
|
|
@@ -2739,7 +2796,7 @@ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
|
|
|
2739
2796
|
return platform === "linux" ? "mounted" : "isolated";
|
|
2740
2797
|
return config2.burnWorkspace;
|
|
2741
2798
|
}
|
|
2742
|
-
function buildIsolatedSetupCommand(tempBranch, setupCommand) {
|
|
2799
|
+
function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
|
|
2743
2800
|
const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
|
|
2744
2801
|
const parts = [
|
|
2745
2802
|
`git config --global --add safe.directory '*'`,
|
|
@@ -2747,8 +2804,13 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand) {
|
|
|
2747
2804
|
`printf '#!/bin/sh\\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE\\ngit push --quiet origin HEAD:%s && exec git -C ${SANDBOX_WORKSPACE_PATH} reset --hard --quiet %s\\n' '${tempBranch}' '${tempBranch}' > ${hookFile}`,
|
|
2748
2805
|
`chmod +x ${hookFile}`
|
|
2749
2806
|
];
|
|
2807
|
+
if (pm === "pnpm" || pm === "yarn") {
|
|
2808
|
+
const shim = `$HOME/.local/bin/${pm}`;
|
|
2809
|
+
parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
|
|
2810
|
+
}
|
|
2750
2811
|
if (setupCommand)
|
|
2751
2812
|
parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
|
|
2813
|
+
parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
|
|
2752
2814
|
return parts.join(" && ");
|
|
2753
2815
|
}
|
|
2754
2816
|
function buildWorkspaceNotes(mode) {
|
|
@@ -2758,7 +2820,7 @@ function buildWorkspaceNotes(mode) {
|
|
|
2758
2820
|
return [
|
|
2759
2821
|
`Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
|
|
2760
2822
|
"",
|
|
2761
|
-
`The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal.`,
|
|
2823
|
+
`The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
|
|
2762
2824
|
"",
|
|
2763
2825
|
`If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`
|
|
2764
2826
|
].join(`
|
|
@@ -2834,6 +2896,55 @@ function isMergeConflictError(err) {
|
|
|
2834
2896
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2835
2897
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2836
2898
|
}
|
|
2899
|
+
function classifyTicketRunError(err) {
|
|
2900
|
+
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
|
|
2901
|
+
if (FATAL_ERROR_PATTERNS.some((p) => p.test(msg)))
|
|
2902
|
+
return "fatal";
|
|
2903
|
+
if (RETRYABLE_ERROR_PATTERNS.some((p) => p.test(msg)))
|
|
2904
|
+
return "retryable";
|
|
2905
|
+
return "fatal";
|
|
2906
|
+
}
|
|
2907
|
+
function retryDelayMs(attempt) {
|
|
2908
|
+
return Math.min(30000, 5000 * 2 ** (attempt - 1));
|
|
2909
|
+
}
|
|
2910
|
+
function buildRetryNotes(input) {
|
|
2911
|
+
const cause = input.error ? ` (${input.error})` : "";
|
|
2912
|
+
const commits = input.commitCount > 0 ? `${input.commitCount} commit(s) from the previous attempt(s) are already on your branch \u2014 completed work, not noise.` : "The previous attempt had not committed anything yet, so you are effectively starting clean.";
|
|
2913
|
+
return [
|
|
2914
|
+
"## Recovery context \u2014 a previous attempt was interrupted",
|
|
2915
|
+
"",
|
|
2916
|
+
`A previous agent working THIS SAME ticket was killed by a transient infrastructure error${cause} \u2014 not by anything it did wrong, and not by a human decision. You are picking up where it left off.`,
|
|
2917
|
+
"",
|
|
2918
|
+
commits,
|
|
2919
|
+
"",
|
|
2920
|
+
"Before doing anything else, run `git log --oneline -15` and `git status` to see what was already completed. Build on that work \u2014 do NOT revert or redo existing commits. Uncommitted changes from the previous attempt were lost; only commits survived. If the ticket turns out to be fully implemented already, verify the acceptance criteria and finish normally."
|
|
2921
|
+
].join(`
|
|
2922
|
+
`);
|
|
2923
|
+
}
|
|
2924
|
+
function delayUnlessAborted(ms, signal) {
|
|
2925
|
+
return new Promise((resolve6) => {
|
|
2926
|
+
if (signal.aborted) {
|
|
2927
|
+
resolve6();
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
const onAbort = () => {
|
|
2931
|
+
clearTimeout(t);
|
|
2932
|
+
resolve6();
|
|
2933
|
+
};
|
|
2934
|
+
const t = setTimeout(() => {
|
|
2935
|
+
signal.removeEventListener("abort", onAbort);
|
|
2936
|
+
resolve6();
|
|
2937
|
+
}, ms);
|
|
2938
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
function stopTicketRun(ticketId) {
|
|
2942
|
+
const controller = activeTicketAborts.get(ticketId);
|
|
2943
|
+
if (!controller)
|
|
2944
|
+
return false;
|
|
2945
|
+
controller.abort(new Error("ticket stopped by user"));
|
|
2946
|
+
return true;
|
|
2947
|
+
}
|
|
2837
2948
|
function createSerialQueue() {
|
|
2838
2949
|
let tail = Promise.resolve();
|
|
2839
2950
|
return (task) => {
|
|
@@ -3053,12 +3164,11 @@ function selectSandbox(config2, mounts = []) {
|
|
|
3053
3164
|
}
|
|
3054
3165
|
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
|
|
3055
3166
|
const { project, feature } = ctx;
|
|
3056
|
-
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
3057
3167
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3058
3168
|
if (workspaceMode === "isolated")
|
|
3059
3169
|
await ensureIsolatedPushTarget();
|
|
3060
3170
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
3061
|
-
const
|
|
3171
|
+
const basePrompt = renderTicketPrompt(template, {
|
|
3062
3172
|
TICKET_JSON: buildTicketJson(ticket),
|
|
3063
3173
|
FEATURE_BRIEF: buildFeatureBrief(feature),
|
|
3064
3174
|
DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
|
|
@@ -3076,14 +3186,6 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3076
3186
|
mounts.push(mount);
|
|
3077
3187
|
}
|
|
3078
3188
|
}
|
|
3079
|
-
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand) : setupCommand;
|
|
3080
|
-
if (hookCommand) {
|
|
3081
|
-
ctx.emitEvent({
|
|
3082
|
-
type: "burn.setup",
|
|
3083
|
-
message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
|
|
3084
|
-
ticketId: ticket.id
|
|
3085
|
-
});
|
|
3086
|
-
}
|
|
3087
3189
|
mkdirSync4(logsDir(), { recursive: true });
|
|
3088
3190
|
const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
3089
3191
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
@@ -3100,70 +3202,177 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3100
3202
|
});
|
|
3101
3203
|
}
|
|
3102
3204
|
};
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3205
|
+
const ticketAbort = new AbortController;
|
|
3206
|
+
activeTicketAborts.set(ticket.id, ticketAbort);
|
|
3207
|
+
const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
|
|
3208
|
+
const maxAttempts = Math.max(1, config2.burnAttempts);
|
|
3209
|
+
let baseBranch = feature.branch;
|
|
3210
|
+
let retryNotes;
|
|
3211
|
+
if (ticket.attemptBranch) {
|
|
3212
|
+
const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
|
|
3213
|
+
if (preserved.length > 0) {
|
|
3214
|
+
baseBranch = ticket.attemptBranch;
|
|
3215
|
+
retryNotes = buildRetryNotes({
|
|
3216
|
+
...ticket.error ? { error: errorHeadline(ticket.error) } : {},
|
|
3217
|
+
commitCount: preserved.length
|
|
3218
|
+
});
|
|
3219
|
+
ctx.emitEvent({
|
|
3220
|
+
type: "ticket.resuming",
|
|
3221
|
+
message: `ticket ${ticket.seq}: resuming from ${preserved.length} preserved commit(s) of a previous attempt`,
|
|
3222
|
+
ticketId: ticket.id,
|
|
3223
|
+
data: { attemptBranch: ticket.attemptBranch, preservedCommits: preserved.length }
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
const preserveChain = (branch) => {
|
|
3228
|
+
ctx.updateTicket(ticket.id, { attemptBranch: branch });
|
|
3120
3229
|
};
|
|
3121
|
-
let result;
|
|
3122
3230
|
try {
|
|
3123
|
-
result
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3231
|
+
let result;
|
|
3232
|
+
let tempBranch = "";
|
|
3233
|
+
for (let attempt = 1;; attempt++) {
|
|
3234
|
+
tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
3235
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
|
|
3236
|
+
if (hookCommand && attempt === 1) {
|
|
3237
|
+
ctx.emitEvent({
|
|
3238
|
+
type: "burn.setup",
|
|
3239
|
+
message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
|
|
3240
|
+
ticketId: ticket.id
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
const runOptions = {
|
|
3244
|
+
agent: buildBurnAgent(config2, token, model),
|
|
3245
|
+
sandbox: selectSandbox(config2, mounts),
|
|
3246
|
+
cwd: project.repoPath,
|
|
3247
|
+
prompt: retryNotes ? `${basePrompt}
|
|
3248
|
+
|
|
3249
|
+
${retryNotes}` : basePrompt,
|
|
3250
|
+
branchStrategy: { type: "branch", branch: tempBranch, baseBranch },
|
|
3251
|
+
signal,
|
|
3252
|
+
name: `ticket-${ticket.seq}`,
|
|
3253
|
+
maxIterations: config2.burnMaxIterations,
|
|
3254
|
+
...hookCommand ? {
|
|
3255
|
+
hooks: {
|
|
3256
|
+
sandbox: {
|
|
3257
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
} : {},
|
|
3261
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
3138
3262
|
};
|
|
3263
|
+
try {
|
|
3264
|
+
result = await run(runOptions);
|
|
3265
|
+
break;
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
if (ctx.signal.aborted)
|
|
3268
|
+
throw err;
|
|
3269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3270
|
+
const salvaged = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
|
|
3271
|
+
if (salvaged.length > 0)
|
|
3272
|
+
baseBranch = tempBranch;
|
|
3273
|
+
if (ticketAbort.signal.aborted) {
|
|
3274
|
+
if (salvaged.length > 0)
|
|
3275
|
+
preserveChain(tempBranch);
|
|
3276
|
+
return {
|
|
3277
|
+
status: "failed",
|
|
3278
|
+
error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
|
|
3279
|
+
event: {
|
|
3280
|
+
type: "ticket.stopped",
|
|
3281
|
+
message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
3285
|
+
if (isMergeConflictError(err)) {
|
|
3286
|
+
return {
|
|
3287
|
+
status: "failed",
|
|
3288
|
+
error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
|
|
3289
|
+
event: {
|
|
3290
|
+
type: "merge.conflict.needs-human",
|
|
3291
|
+
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
|
|
3292
|
+
}
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
if (classifyTicketRunError(err) === "retryable" && attempt < maxAttempts) {
|
|
3296
|
+
const headline = errorHeadline(msg);
|
|
3297
|
+
retryNotes = buildRetryNotes({ error: headline, commitCount: salvaged.length });
|
|
3298
|
+
ctx.emitEvent({
|
|
3299
|
+
type: "ticket.retrying",
|
|
3300
|
+
message: `ticket ${ticket.seq} attempt ${attempt}/${maxAttempts} died (${headline}) \u2014 retrying${salvaged.length > 0 ? ` from ${salvaged.length} preserved commit(s)` : ""}`,
|
|
3301
|
+
ticketId: ticket.id,
|
|
3302
|
+
data: { attempt, maxAttempts, error: msg, preservedCommits: salvaged.length }
|
|
3303
|
+
});
|
|
3304
|
+
appendTranscript(ticket.id, {
|
|
3305
|
+
kind: "text",
|
|
3306
|
+
text: `
|
|
3307
|
+
\u2014 attempt ${attempt} interrupted (${headline}); starting attempt ${attempt + 1}${salvaged.length > 0 ? " with committed work preserved" : ""} \u2014
|
|
3308
|
+
`
|
|
3309
|
+
});
|
|
3310
|
+
await delayUnlessAborted(retryDelayMs(attempt), signal);
|
|
3311
|
+
ctx.signal.throwIfAborted();
|
|
3312
|
+
if (ticketAbort.signal.aborted) {
|
|
3313
|
+
if (salvaged.length > 0)
|
|
3314
|
+
preserveChain(tempBranch);
|
|
3315
|
+
return {
|
|
3316
|
+
status: "failed",
|
|
3317
|
+
error: `stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved; retry to continue from them` : ""}`,
|
|
3318
|
+
event: {
|
|
3319
|
+
type: "ticket.stopped",
|
|
3320
|
+
message: `ticket ${ticket.seq} stopped by user${salvaged.length > 0 ? ` \u2014 ${salvaged.length} commit(s) preserved for retry` : ""}`
|
|
3321
|
+
}
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
if (salvaged.length > 0)
|
|
3327
|
+
preserveChain(tempBranch);
|
|
3328
|
+
return { status: "failed", error: msg };
|
|
3329
|
+
}
|
|
3139
3330
|
}
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3331
|
+
if (!result)
|
|
3332
|
+
return { status: "failed", error: "internal: run loop produced no result" };
|
|
3333
|
+
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
3334
|
+
const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
|
|
3335
|
+
let outcome;
|
|
3336
|
+
if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
|
|
3337
|
+
outcome = { status: "failed", error: `agent reported BLOCKED:
|
|
3338
|
+
${blocked.trim()}` };
|
|
3339
|
+
} else if (chain.length > 0) {
|
|
3340
|
+
outcome = { status: "done", commits: chain };
|
|
3341
|
+
} else {
|
|
3342
|
+
outcome = interpretRunResult(result, blocked);
|
|
3343
|
+
}
|
|
3344
|
+
if (outcome.status !== "done") {
|
|
3345
|
+
if (chain.length > 0)
|
|
3346
|
+
preserveChain(tempBranch);
|
|
3347
|
+
return outcome;
|
|
3348
|
+
}
|
|
3349
|
+
const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
|
|
3350
|
+
if (!merge.ok) {
|
|
3351
|
+
const detail = merge.error ?? "merge failed";
|
|
3352
|
+
if (merge.conflict) {
|
|
3353
|
+
return {
|
|
3354
|
+
status: "failed",
|
|
3355
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
3356
|
+
event: {
|
|
3357
|
+
type: "merge.conflict.needs-human",
|
|
3358
|
+
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3359
|
+
}
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
preserveChain(tempBranch);
|
|
3152
3363
|
return {
|
|
3153
3364
|
status: "failed",
|
|
3154
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch}
|
|
3155
|
-
event: {
|
|
3156
|
-
type: "merge.conflict.needs-human",
|
|
3157
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3158
|
-
}
|
|
3365
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3159
3366
|
};
|
|
3160
3367
|
}
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3368
|
+
if (ticket.attemptBranch)
|
|
3369
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null });
|
|
3370
|
+
return outcome;
|
|
3371
|
+
} finally {
|
|
3372
|
+
activeTicketAborts.delete(ticket.id);
|
|
3373
|
+
throttle.flush();
|
|
3374
|
+
endTranscript(ticket.id);
|
|
3165
3375
|
}
|
|
3166
|
-
return outcome;
|
|
3167
3376
|
}
|
|
3168
3377
|
function resolveBurnDeps(ctx) {
|
|
3169
3378
|
const config2 = loadConfig();
|
|
@@ -3191,7 +3400,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3191
3400
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3192
3401
|
return token && token.length > 0 ? token : undefined;
|
|
3193
3402
|
}
|
|
3194
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_HOOK_TIMEOUT_MS, ticketBurner;
|
|
3403
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
|
|
3195
3404
|
var init_ticket_burner = __esm(() => {
|
|
3196
3405
|
init_src();
|
|
3197
3406
|
init_config_load();
|
|
@@ -3212,6 +3421,25 @@ var init_ticket_burner = __esm(() => {
|
|
|
3212
3421
|
yarn: "~/.cache/yarn",
|
|
3213
3422
|
npm: "~/.npm"
|
|
3214
3423
|
};
|
|
3424
|
+
FATAL_ERROR_PATTERNS = [
|
|
3425
|
+
/invalid (api key|x-api-key)/i,
|
|
3426
|
+
/authentication|unauthorized|permission denied/i,
|
|
3427
|
+
/credit balance|billing/i,
|
|
3428
|
+
/oauth token|setup-token/i,
|
|
3429
|
+
/issue with the selected model|model not found|unknown model/i,
|
|
3430
|
+
/does not support resumeSession|resumeSession .* not found/i
|
|
3431
|
+
];
|
|
3432
|
+
RETRYABLE_ERROR_PATTERNS = [
|
|
3433
|
+
/exited with code \d+/i,
|
|
3434
|
+
/idle timeout|AgentIdleTimeout/i,
|
|
3435
|
+
/connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
|
|
3436
|
+
/econnreset|etimedout|econnrefused|epipe|eai_again/i,
|
|
3437
|
+
/overloaded|rate.?limit|too many requests/i,
|
|
3438
|
+
/internal server error|service unavailable|bad gateway|gateway timeout/i,
|
|
3439
|
+
/\bapi error\b/i,
|
|
3440
|
+
/session capture failed/i
|
|
3441
|
+
];
|
|
3442
|
+
activeTicketAborts = new Map;
|
|
3215
3443
|
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
3216
3444
|
ticketBurner = {
|
|
3217
3445
|
id: "ticket-burner",
|
|
@@ -3655,7 +3883,7 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3655
3883
|
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3656
3884
|
}
|
|
3657
3885
|
if (restarting) {
|
|
3658
|
-
const failed = tickets2.filter((t) => t.status === "failed");
|
|
3886
|
+
const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
|
|
3659
3887
|
for (const t of failed) {
|
|
3660
3888
|
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
3661
3889
|
}
|
|
@@ -3672,6 +3900,69 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3672
3900
|
});
|
|
3673
3901
|
return { runId };
|
|
3674
3902
|
}
|
|
3903
|
+
async function retryTicket(ctx, ticketId, opts = {}) {
|
|
3904
|
+
const ticket = getTicket(ctx, ticketId);
|
|
3905
|
+
const feature = getFeatureRow(ctx, ticket.featureId);
|
|
3906
|
+
if (ticket.status !== "failed") {
|
|
3907
|
+
throw new GateError(`only failed tickets can be retried \u2014 ticket ${ticket.seq} is ${ticket.status}`);
|
|
3908
|
+
}
|
|
3909
|
+
if (hasActiveRun(ctx, feature.id)) {
|
|
3910
|
+
throw new GateError("a run is live for this feature \u2014 retry after it finishes, or cancel it first");
|
|
3911
|
+
}
|
|
3912
|
+
const project = projectForFeature(ctx, feature);
|
|
3913
|
+
const all = listByFeature2(ctx, feature.id);
|
|
3914
|
+
const bySeq = new Map(all.map((t) => [t.seq, t]));
|
|
3915
|
+
const toReset = new Map([[ticket.seq, ticket]]);
|
|
3916
|
+
const queue = [ticket.seq];
|
|
3917
|
+
while (queue.length > 0) {
|
|
3918
|
+
const t = bySeq.get(queue.shift());
|
|
3919
|
+
if (!t)
|
|
3920
|
+
continue;
|
|
3921
|
+
for (const b of t.blockedBy) {
|
|
3922
|
+
const blocker = bySeq.get(b);
|
|
3923
|
+
if (blocker && blocker.status === "failed" && !toReset.has(blocker.seq)) {
|
|
3924
|
+
toReset.set(blocker.seq, blocker);
|
|
3925
|
+
queue.push(blocker.seq);
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
let resumedFrom = ticket.attemptBranch ?? null;
|
|
3930
|
+
let preservedCommits = 0;
|
|
3931
|
+
if (opts.fresh) {
|
|
3932
|
+
const discard = new Set(await listTicketAttemptBranches(project.repoPath, feature.slug, ticket.seq));
|
|
3933
|
+
if (ticket.attemptBranch)
|
|
3934
|
+
discard.add(ticket.attemptBranch);
|
|
3935
|
+
for (const b of discard)
|
|
3936
|
+
await deleteTempBranch(project.repoPath, b);
|
|
3937
|
+
resumedFrom = null;
|
|
3938
|
+
} else if (resumedFrom) {
|
|
3939
|
+
preservedCommits = (await branchCommitsAhead(project.repoPath, feature.branch, resumedFrom)).length;
|
|
3940
|
+
} else {
|
|
3941
|
+
const found = await findPreservedTicketBranch(project.repoPath, feature.branch, feature.slug, ticket.seq);
|
|
3942
|
+
if (found) {
|
|
3943
|
+
resumedFrom = found.branch;
|
|
3944
|
+
preservedCommits = found.commits.length;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
for (const t of toReset.values()) {
|
|
3948
|
+
const isTarget = t.id === ticket.id;
|
|
3949
|
+
updateTicket(ctx, t.id, {
|
|
3950
|
+
status: "pending",
|
|
3951
|
+
error: null,
|
|
3952
|
+
...isTarget ? { attemptBranch: resumedFrom } : {}
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
const seqs = [...toReset.keys()].sort((a, b) => a - b);
|
|
3956
|
+
const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
3957
|
+
emit(ctx, feature.id, {
|
|
3958
|
+
type: "ticket.retry",
|
|
3959
|
+
message: seqs.length > 1 ? `retrying ticket ${ticket.seq}${how} (+ failed blocker${seqs.length > 2 ? "s" : ""} ${seqs.filter((s) => s !== ticket.seq).join(", ")})` : `retrying ticket ${ticket.seq}${how}`,
|
|
3960
|
+
ticketId: ticket.id,
|
|
3961
|
+
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
|
|
3962
|
+
});
|
|
3963
|
+
const { runId } = await burn(ctx, feature.id, { resetFailed: false });
|
|
3964
|
+
return { runId, retried: seqs, resumedFrom, preservedCommits };
|
|
3965
|
+
}
|
|
3675
3966
|
function escalateToMap(ctx, featureId, input) {
|
|
3676
3967
|
const feature = getFeatureRow(ctx, featureId);
|
|
3677
3968
|
if (feature.mapped) {
|
|
@@ -5354,6 +5645,14 @@ var init_settings = __esm(() => {
|
|
|
5354
5645
|
valueSchema: z9.number().int().min(1).max(10),
|
|
5355
5646
|
parseEnv: (raw) => Number(raw)
|
|
5356
5647
|
},
|
|
5648
|
+
{
|
|
5649
|
+
key: "burnAttempts",
|
|
5650
|
+
configKey: "burnAttempts",
|
|
5651
|
+
envVar: "RUNCASTLE_BURN_ATTEMPTS",
|
|
5652
|
+
restartRequired: false,
|
|
5653
|
+
valueSchema: z9.number().int().min(1).max(5),
|
|
5654
|
+
parseEnv: (raw) => Number(raw)
|
|
5655
|
+
},
|
|
5357
5656
|
{
|
|
5358
5657
|
key: "setupCommand",
|
|
5359
5658
|
configKey: "setupCommand",
|
|
@@ -5899,6 +6198,21 @@ var init_system = __esm(() => {
|
|
|
5899
6198
|
});
|
|
5900
6199
|
});
|
|
5901
6200
|
|
|
6201
|
+
// src/trpc/routers/ticket.ts
|
|
6202
|
+
import * as z12 from "zod";
|
|
6203
|
+
var ticketRouter;
|
|
6204
|
+
var init_ticket = __esm(() => {
|
|
6205
|
+
init_features();
|
|
6206
|
+
init_tickets();
|
|
6207
|
+
init_ticket_burner();
|
|
6208
|
+
init_context();
|
|
6209
|
+
ticketRouter = router({
|
|
6210
|
+
retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
|
|
6211
|
+
stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ input }) => ({ stopped: stopTicketRun(input.ticketId) })),
|
|
6212
|
+
cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
|
|
6213
|
+
});
|
|
6214
|
+
});
|
|
6215
|
+
|
|
5902
6216
|
// src/trpc/router.ts
|
|
5903
6217
|
var appRouter;
|
|
5904
6218
|
var init_router = __esm(() => {
|
|
@@ -5911,10 +6225,12 @@ var init_router = __esm(() => {
|
|
|
5911
6225
|
init_settings2();
|
|
5912
6226
|
init_setup2();
|
|
5913
6227
|
init_system();
|
|
6228
|
+
init_ticket();
|
|
5914
6229
|
appRouter = router({
|
|
5915
6230
|
project: projectRouter,
|
|
5916
6231
|
feature: featureRouter,
|
|
5917
6232
|
run: runRouter,
|
|
6233
|
+
ticket: ticketRouter,
|
|
5918
6234
|
events: eventsRouter,
|
|
5919
6235
|
docs: docsRouter,
|
|
5920
6236
|
settings: settingsRouter,
|