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/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
|
}
|
|
@@ -2740,7 +2797,7 @@ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
|
|
|
2740
2797
|
return platform === "linux" ? "mounted" : "isolated";
|
|
2741
2798
|
return config2.burnWorkspace;
|
|
2742
2799
|
}
|
|
2743
|
-
function buildIsolatedSetupCommand(tempBranch, setupCommand) {
|
|
2800
|
+
function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
|
|
2744
2801
|
const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
|
|
2745
2802
|
const parts = [
|
|
2746
2803
|
`git config --global --add safe.directory '*'`,
|
|
@@ -2748,8 +2805,13 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand) {
|
|
|
2748
2805
|
`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}`,
|
|
2749
2806
|
`chmod +x ${hookFile}`
|
|
2750
2807
|
];
|
|
2808
|
+
if (pm === "pnpm" || pm === "yarn") {
|
|
2809
|
+
const shim = `$HOME/.local/bin/${pm}`;
|
|
2810
|
+
parts.push(`mkdir -p "$HOME/.local/bin"`, `printf '#!/bin/sh\\nexec corepack ${pm} "$@"\\n' > "${shim}"`, `chmod +x "${shim}"`);
|
|
2811
|
+
}
|
|
2751
2812
|
if (setupCommand)
|
|
2752
2813
|
parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
|
|
2814
|
+
parts.push(`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks`);
|
|
2753
2815
|
return parts.join(" && ");
|
|
2754
2816
|
}
|
|
2755
2817
|
function buildWorkspaceNotes(mode) {
|
|
@@ -2759,7 +2821,7 @@ function buildWorkspaceNotes(mode) {
|
|
|
2759
2821
|
return [
|
|
2760
2822
|
`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.`,
|
|
2761
2823
|
"",
|
|
2762
|
-
`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.`,
|
|
2824
|
+
`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.`,
|
|
2763
2825
|
"",
|
|
2764
2826
|
`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.`
|
|
2765
2827
|
].join(`
|
|
@@ -2835,6 +2897,55 @@ function isMergeConflictError(err) {
|
|
|
2835
2897
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2836
2898
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2837
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
|
+
}
|
|
2838
2949
|
function createSerialQueue() {
|
|
2839
2950
|
let tail = Promise.resolve();
|
|
2840
2951
|
return (task) => {
|
|
@@ -3054,12 +3165,11 @@ function selectSandbox(config2, mounts = []) {
|
|
|
3054
3165
|
}
|
|
3055
3166
|
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
|
|
3056
3167
|
const { project, feature } = ctx;
|
|
3057
|
-
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
3058
3168
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3059
3169
|
if (workspaceMode === "isolated")
|
|
3060
3170
|
await ensureIsolatedPushTarget();
|
|
3061
3171
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
3062
|
-
const
|
|
3172
|
+
const basePrompt = renderTicketPrompt(template, {
|
|
3063
3173
|
TICKET_JSON: buildTicketJson(ticket),
|
|
3064
3174
|
FEATURE_BRIEF: buildFeatureBrief(feature),
|
|
3065
3175
|
DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
|
|
@@ -3077,14 +3187,6 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3077
3187
|
mounts.push(mount);
|
|
3078
3188
|
}
|
|
3079
3189
|
}
|
|
3080
|
-
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand) : setupCommand;
|
|
3081
|
-
if (hookCommand) {
|
|
3082
|
-
ctx.emitEvent({
|
|
3083
|
-
type: "burn.setup",
|
|
3084
|
-
message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
|
|
3085
|
-
ticketId: ticket.id
|
|
3086
|
-
});
|
|
3087
|
-
}
|
|
3088
3190
|
mkdirSync4(logsDir(), { recursive: true });
|
|
3089
3191
|
const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
3090
3192
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
@@ -3101,70 +3203,177 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3101
3203
|
});
|
|
3102
3204
|
}
|
|
3103
3205
|
};
|
|
3104
|
-
const
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
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 });
|
|
3121
3230
|
};
|
|
3122
|
-
let result;
|
|
3123
3231
|
try {
|
|
3124
|
-
result
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
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 }
|
|
3139
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
|
+
}
|
|
3140
3331
|
}
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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);
|
|
3153
3364
|
return {
|
|
3154
3365
|
status: "failed",
|
|
3155
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch}
|
|
3156
|
-
event: {
|
|
3157
|
-
type: "merge.conflict.needs-human",
|
|
3158
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3159
|
-
}
|
|
3366
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3160
3367
|
};
|
|
3161
3368
|
}
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
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);
|
|
3166
3376
|
}
|
|
3167
|
-
return outcome;
|
|
3168
3377
|
}
|
|
3169
3378
|
function resolveBurnDeps(ctx) {
|
|
3170
3379
|
const config2 = loadConfig();
|
|
@@ -3192,7 +3401,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3192
3401
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3193
3402
|
return token && token.length > 0 ? token : undefined;
|
|
3194
3403
|
}
|
|
3195
|
-
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;
|
|
3196
3405
|
var init_ticket_burner = __esm(() => {
|
|
3197
3406
|
init_src();
|
|
3198
3407
|
init_config_load();
|
|
@@ -3213,6 +3422,25 @@ var init_ticket_burner = __esm(() => {
|
|
|
3213
3422
|
yarn: "~/.cache/yarn",
|
|
3214
3423
|
npm: "~/.npm"
|
|
3215
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;
|
|
3216
3444
|
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
3217
3445
|
ticketBurner = {
|
|
3218
3446
|
id: "ticket-burner",
|
|
@@ -3656,7 +3884,7 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3656
3884
|
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3657
3885
|
}
|
|
3658
3886
|
if (restarting) {
|
|
3659
|
-
const failed = tickets2.filter((t) => t.status === "failed");
|
|
3887
|
+
const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
|
|
3660
3888
|
for (const t of failed) {
|
|
3661
3889
|
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
3662
3890
|
}
|
|
@@ -3673,6 +3901,69 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3673
3901
|
});
|
|
3674
3902
|
return { runId };
|
|
3675
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
|
+
}
|
|
3676
3967
|
function escalateToMap(ctx, featureId, input) {
|
|
3677
3968
|
const feature = getFeatureRow(ctx, featureId);
|
|
3678
3969
|
if (feature.mapped) {
|
|
@@ -5355,6 +5646,14 @@ var init_settings = __esm(() => {
|
|
|
5355
5646
|
valueSchema: z9.number().int().min(1).max(10),
|
|
5356
5647
|
parseEnv: (raw) => Number(raw)
|
|
5357
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
|
+
},
|
|
5358
5657
|
{
|
|
5359
5658
|
key: "setupCommand",
|
|
5360
5659
|
configKey: "setupCommand",
|
|
@@ -5900,6 +6199,21 @@ var init_system = __esm(() => {
|
|
|
5900
6199
|
});
|
|
5901
6200
|
});
|
|
5902
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
|
+
|
|
5903
6217
|
// src/trpc/router.ts
|
|
5904
6218
|
var appRouter;
|
|
5905
6219
|
var init_router = __esm(() => {
|
|
@@ -5912,10 +6226,12 @@ var init_router = __esm(() => {
|
|
|
5912
6226
|
init_settings2();
|
|
5913
6227
|
init_setup2();
|
|
5914
6228
|
init_system();
|
|
6229
|
+
init_ticket();
|
|
5915
6230
|
appRouter = router({
|
|
5916
6231
|
project: projectRouter,
|
|
5917
6232
|
feature: featureRouter,
|
|
5918
6233
|
run: runRouter,
|
|
6234
|
+
ticket: ticketRouter,
|
|
5919
6235
|
events: eventsRouter,
|
|
5920
6236
|
docs: docsRouter,
|
|
5921
6237
|
settings: settingsRouter,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `tickets` ADD `attempt_branch` text;
|