runcastle 1.0.21 → 1.0.22
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 +266 -55
- package/drizzle/0009_pale_agent_zero.sql +1 -0
- package/drizzle/meta/0009_snapshot.json +610 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +266 -55
- package/package.json +1 -1
- package/skills/burner/resolve-conflict.md +69 -0
- package/web/assets/{index-B5UytNW_.js → index-BcWKL2hj.js} +20 -20
- package/web/assets/{index-BIDKY6fS.css → index-fWdYT4eC.css} +1 -1
- package/web/index.html +2 -2
package/bin/runcastle.js
CHANGED
|
@@ -107,6 +107,7 @@ var init_config = __esm(() => {
|
|
|
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
109
|
burnAttempts: z.number().int().min(1).max(5).default(3),
|
|
110
|
+
burnConflictAttempts: z.number().int().min(0).max(3).default(2),
|
|
110
111
|
setupCommand: z.string().optional(),
|
|
111
112
|
burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
|
|
112
113
|
}));
|
|
@@ -150,6 +151,9 @@ function loadConfig(env = process.env) {
|
|
|
150
151
|
if (env.RUNCASTLE_BURN_ATTEMPTS) {
|
|
151
152
|
overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
|
|
152
153
|
}
|
|
154
|
+
if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
|
|
155
|
+
overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
|
|
156
|
+
}
|
|
153
157
|
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
154
158
|
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
155
159
|
if (env.RUNCASTLE_BURN_WORKSPACE)
|
|
@@ -216,7 +220,8 @@ var init_schemas = __esm(() => {
|
|
|
216
220
|
status: TicketStatus,
|
|
217
221
|
commits: z2.array(z2.string()),
|
|
218
222
|
error: z2.string().optional(),
|
|
219
|
-
attemptBranch: z2.string().optional()
|
|
223
|
+
attemptBranch: z2.string().optional(),
|
|
224
|
+
conflictFiles: z2.array(z2.string()).optional()
|
|
220
225
|
});
|
|
221
226
|
WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
|
|
222
227
|
WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
|
|
@@ -491,7 +496,8 @@ var init_db_schema = __esm(() => {
|
|
|
491
496
|
status: text("status").notNull().$type(),
|
|
492
497
|
commits: text("commits", { mode: "json" }).notNull().$type(),
|
|
493
498
|
error: text("error"),
|
|
494
|
-
attemptBranch: text("attempt_branch")
|
|
499
|
+
attemptBranch: text("attempt_branch"),
|
|
500
|
+
conflictFiles: text("conflict_files", { mode: "json" }).$type()
|
|
495
501
|
});
|
|
496
502
|
waypoints = sqliteTable("waypoints", {
|
|
497
503
|
id: text("id").primaryKey(),
|
|
@@ -1707,7 +1713,8 @@ function rowToTicket(row) {
|
|
|
1707
1713
|
status: row.status,
|
|
1708
1714
|
commits: row.commits,
|
|
1709
1715
|
error: row.error ?? undefined,
|
|
1710
|
-
attemptBranch: row.attemptBranch ?? undefined
|
|
1716
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
1717
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
1711
1718
|
};
|
|
1712
1719
|
}
|
|
1713
1720
|
function resolveBlocking(inputs, startSeq) {
|
|
@@ -1741,7 +1748,8 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1741
1748
|
status: "pending",
|
|
1742
1749
|
commits: [],
|
|
1743
1750
|
error: null,
|
|
1744
|
-
attemptBranch: null
|
|
1751
|
+
attemptBranch: null,
|
|
1752
|
+
conflictFiles: null
|
|
1745
1753
|
}));
|
|
1746
1754
|
ctx.db.insert(tickets).values(rows).run();
|
|
1747
1755
|
emit(ctx, featureId, {
|
|
@@ -1813,6 +1821,8 @@ function updateTicket(ctx, id, patch) {
|
|
|
1813
1821
|
set.error = patch.error;
|
|
1814
1822
|
if (patch.attemptBranch !== undefined)
|
|
1815
1823
|
set.attemptBranch = patch.attemptBranch;
|
|
1824
|
+
if (patch.conflictFiles !== undefined)
|
|
1825
|
+
set.conflictFiles = patch.conflictFiles;
|
|
1816
1826
|
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1817
1827
|
emit(ctx, current2.featureId, {
|
|
1818
1828
|
type: "ticket.updated",
|
|
@@ -2199,6 +2209,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2199
2209
|
return [];
|
|
2200
2210
|
}
|
|
2201
2211
|
}
|
|
2212
|
+
async function commitSummaries(repoPath, base, branch, limit = 20) {
|
|
2213
|
+
try {
|
|
2214
|
+
const out = await git(repoPath).raw([
|
|
2215
|
+
"log",
|
|
2216
|
+
`--max-count=${limit}`,
|
|
2217
|
+
"--format=%h %s",
|
|
2218
|
+
`${base}..${branch}`
|
|
2219
|
+
]);
|
|
2220
|
+
return out.split(`
|
|
2221
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
2222
|
+
} catch {
|
|
2223
|
+
return [];
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2202
2226
|
async function deleteTempBranch(repoPath, branch) {
|
|
2203
2227
|
try {
|
|
2204
2228
|
return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
@@ -2261,8 +2285,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
|
2261
2285
|
await gw.merge([tempBranch]);
|
|
2262
2286
|
} catch (e) {
|
|
2263
2287
|
if (await mergeInProgress(gw)) {
|
|
2288
|
+
const files = await conflictedFiles(gw);
|
|
2264
2289
|
await gw.raw(["merge", "--abort"]);
|
|
2265
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2290
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2266
2291
|
}
|
|
2267
2292
|
return { ok: false, error: errMsg(e) };
|
|
2268
2293
|
}
|
|
@@ -2293,8 +2318,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
|
|
|
2293
2318
|
await gw.merge([tempBranch]);
|
|
2294
2319
|
} catch (e) {
|
|
2295
2320
|
if (await mergeInProgress(gw)) {
|
|
2321
|
+
const files = await conflictedFiles(gw);
|
|
2296
2322
|
await gw.raw(["merge", "--abort"]);
|
|
2297
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2323
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2298
2324
|
}
|
|
2299
2325
|
return { ok: false, error: errMsg(e) };
|
|
2300
2326
|
}
|
|
@@ -2811,13 +2837,16 @@ function detectCycle(tickets2) {
|
|
|
2811
2837
|
}
|
|
2812
2838
|
return null;
|
|
2813
2839
|
}
|
|
2814
|
-
function
|
|
2840
|
+
function renderTemplate(template, values) {
|
|
2815
2841
|
let out = template;
|
|
2816
|
-
for (const key of
|
|
2817
|
-
out = out.split(`{{${key}}}`).join(
|
|
2842
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2843
|
+
out = out.split(`{{${key}}}`).join(value);
|
|
2818
2844
|
}
|
|
2819
2845
|
return out;
|
|
2820
2846
|
}
|
|
2847
|
+
function renderTicketPrompt(template, values) {
|
|
2848
|
+
return renderTemplate(template, values);
|
|
2849
|
+
}
|
|
2821
2850
|
function buildTicketJson(ticket) {
|
|
2822
2851
|
return JSON.stringify({
|
|
2823
2852
|
seq: ticket.seq,
|
|
@@ -2851,6 +2880,23 @@ ${f.content.trim()}`).join(`
|
|
|
2851
2880
|
|
|
2852
2881
|
`);
|
|
2853
2882
|
}
|
|
2883
|
+
function buildConflictFilesBlock(files) {
|
|
2884
|
+
if (files.length === 0) {
|
|
2885
|
+
return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
|
|
2886
|
+
}
|
|
2887
|
+
return files.map((f) => `- \`${f}\``).join(`
|
|
2888
|
+
`);
|
|
2889
|
+
}
|
|
2890
|
+
function buildOtherSideBlock(summaries) {
|
|
2891
|
+
if (summaries.length === 0) {
|
|
2892
|
+
return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
|
|
2893
|
+
}
|
|
2894
|
+
return summaries.map((s) => `- ${s}`).join(`
|
|
2895
|
+
`);
|
|
2896
|
+
}
|
|
2897
|
+
function resolveMergeCommand(mode, featureBranch2) {
|
|
2898
|
+
return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
|
|
2899
|
+
}
|
|
2854
2900
|
function parseEnvFile(content) {
|
|
2855
2901
|
const out = {};
|
|
2856
2902
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
@@ -3075,6 +3121,42 @@ function createSerialQueue() {
|
|
|
3075
3121
|
return next;
|
|
3076
3122
|
};
|
|
3077
3123
|
}
|
|
3124
|
+
async function landWithResolve(branch, deps) {
|
|
3125
|
+
let current2 = branch;
|
|
3126
|
+
for (let attempt = 1;; attempt++) {
|
|
3127
|
+
const merge = await deps.merge(current2);
|
|
3128
|
+
if (merge.ok)
|
|
3129
|
+
return { status: "landed", branch: current2 };
|
|
3130
|
+
const detail = merge.error ?? "merge failed";
|
|
3131
|
+
if (!merge.conflict)
|
|
3132
|
+
return { status: "failed", branch: current2, error: detail };
|
|
3133
|
+
const files = merge.files ?? [];
|
|
3134
|
+
if (attempt > deps.maxResolveAttempts) {
|
|
3135
|
+
return { status: "conflict", branch: current2, files, error: detail };
|
|
3136
|
+
}
|
|
3137
|
+
const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
|
|
3138
|
+
deps.emit({
|
|
3139
|
+
type: "merge.conflict.resolving",
|
|
3140
|
+
message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
|
|
3141
|
+
data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
|
|
3142
|
+
});
|
|
3143
|
+
const resolved = await deps.resolve({
|
|
3144
|
+
branch: current2,
|
|
3145
|
+
files,
|
|
3146
|
+
attempt,
|
|
3147
|
+
maxAttempts: deps.maxResolveAttempts
|
|
3148
|
+
});
|
|
3149
|
+
current2 = resolved.branch;
|
|
3150
|
+
if (!resolved.ok) {
|
|
3151
|
+
return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
|
|
3152
|
+
}
|
|
3153
|
+
deps.emit({
|
|
3154
|
+
type: "merge.conflict.resolved",
|
|
3155
|
+
message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
|
|
3156
|
+
data: { branch: current2, files, attempt }
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3078
3160
|
function errorHeadline(s) {
|
|
3079
3161
|
const lines = s.split(`
|
|
3080
3162
|
`).map((l) => l.trim()).filter(Boolean);
|
|
@@ -3222,8 +3304,14 @@ async function burnRun(ctx, deps) {
|
|
|
3222
3304
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
3223
3305
|
}
|
|
3224
3306
|
function burnerTemplatePath() {
|
|
3307
|
+
return burnerAssetPath("implement-ticket.md");
|
|
3308
|
+
}
|
|
3309
|
+
function resolverTemplatePath() {
|
|
3310
|
+
return burnerAssetPath("resolve-conflict.md");
|
|
3311
|
+
}
|
|
3312
|
+
function burnerAssetPath(file) {
|
|
3225
3313
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3226
|
-
return join9(resolveSkillsRoot(here), "burner",
|
|
3314
|
+
return join9(resolveSkillsRoot(here), "burner", file);
|
|
3227
3315
|
}
|
|
3228
3316
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3229
3317
|
const worktree = worktreeDir(projectId, slug);
|
|
@@ -3287,13 +3375,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3287
3375
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3288
3376
|
if (workspaceMode === "isolated")
|
|
3289
3377
|
await ensureIsolatedPushTarget();
|
|
3290
|
-
const
|
|
3291
|
-
const
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3378
|
+
const ticketJson = buildTicketJson(ticket);
|
|
3379
|
+
const featureBrief = buildFeatureBrief(feature);
|
|
3380
|
+
const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
|
|
3381
|
+
const workspaceNotes = buildWorkspaceNotes(workspaceMode);
|
|
3382
|
+
const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
|
|
3383
|
+
TICKET_JSON: ticketJson,
|
|
3384
|
+
FEATURE_BRIEF: featureBrief,
|
|
3385
|
+
DOCS_DIGEST: docsDigest,
|
|
3295
3386
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
|
|
3296
|
-
WORKSPACE_NOTES:
|
|
3387
|
+
WORKSPACE_NOTES: workspaceNotes
|
|
3297
3388
|
});
|
|
3298
3389
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3299
3390
|
const pm = detectPackageManager(toolchain);
|
|
@@ -3326,9 +3417,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3326
3417
|
activeTicketAborts.set(ticket.id, ticketAbort);
|
|
3327
3418
|
const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
|
|
3328
3419
|
const maxAttempts = Math.max(1, config2.burnAttempts);
|
|
3420
|
+
const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
|
|
3329
3421
|
let baseBranch = feature.branch;
|
|
3330
3422
|
let retryNotes;
|
|
3331
|
-
if (ticket.attemptBranch) {
|
|
3423
|
+
if (ticket.attemptBranch && !conflictResume) {
|
|
3332
3424
|
const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
|
|
3333
3425
|
if (preserved.length > 0) {
|
|
3334
3426
|
baseBranch = ticket.attemptBranch;
|
|
@@ -3347,7 +3439,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3347
3439
|
const preserveChain = (branch) => {
|
|
3348
3440
|
ctx.updateTicket(ticket.id, { attemptBranch: branch });
|
|
3349
3441
|
};
|
|
3442
|
+
const runResolver = async (input) => {
|
|
3443
|
+
const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
|
|
3444
|
+
const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
|
|
3445
|
+
const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
|
|
3446
|
+
TICKET_JSON: ticketJson,
|
|
3447
|
+
FEATURE_BRIEF: featureBrief,
|
|
3448
|
+
DOCS_DIGEST: docsDigest,
|
|
3449
|
+
WORKSPACE_NOTES: workspaceNotes,
|
|
3450
|
+
FEATURE_BRANCH: feature.branch,
|
|
3451
|
+
CONFLICT_FILES: buildConflictFilesBlock(input.files),
|
|
3452
|
+
OTHER_SIDE: buildOtherSideBlock(otherSide),
|
|
3453
|
+
MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
|
|
3454
|
+
});
|
|
3455
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
|
|
3456
|
+
appendTranscript(ticket.id, {
|
|
3457
|
+
kind: "text",
|
|
3458
|
+
text: `
|
|
3459
|
+
\u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
|
|
3460
|
+
`
|
|
3461
|
+
});
|
|
3462
|
+
const bestTip = async () => {
|
|
3463
|
+
const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
|
|
3464
|
+
return made.length > 0 ? resolveBranch : input.branch;
|
|
3465
|
+
};
|
|
3466
|
+
let result;
|
|
3467
|
+
try {
|
|
3468
|
+
result = await run({
|
|
3469
|
+
agent: buildBurnAgent(config2, token, model),
|
|
3470
|
+
sandbox: selectSandbox(config2, mounts),
|
|
3471
|
+
cwd: project.repoPath,
|
|
3472
|
+
prompt,
|
|
3473
|
+
branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
|
|
3474
|
+
signal,
|
|
3475
|
+
name: `ticket-${ticket.seq}-resolve`,
|
|
3476
|
+
maxIterations: config2.burnMaxIterations,
|
|
3477
|
+
...hookCommand ? {
|
|
3478
|
+
hooks: {
|
|
3479
|
+
sandbox: {
|
|
3480
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
} : {},
|
|
3484
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
3485
|
+
});
|
|
3486
|
+
} catch (err) {
|
|
3487
|
+
if (ctx.signal.aborted)
|
|
3488
|
+
throw err;
|
|
3489
|
+
return {
|
|
3490
|
+
ok: false,
|
|
3491
|
+
branch: await bestTip(),
|
|
3492
|
+
error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
3496
|
+
if (blocked !== undefined && blocked.trim().length > 0) {
|
|
3497
|
+
return {
|
|
3498
|
+
ok: false,
|
|
3499
|
+
branch: await bestTip(),
|
|
3500
|
+
error: `resolver reported BLOCKED:
|
|
3501
|
+
${blocked.trim()}`
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
|
|
3505
|
+
if (missing.length > 0) {
|
|
3506
|
+
return {
|
|
3507
|
+
ok: false,
|
|
3508
|
+
branch: await bestTip(),
|
|
3509
|
+
error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3512
|
+
return { ok: true, branch: resolveBranch };
|
|
3513
|
+
};
|
|
3514
|
+
let landedCommits = [];
|
|
3515
|
+
const landDeps = {
|
|
3516
|
+
merge: (branch) => land(async () => {
|
|
3517
|
+
const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3518
|
+
const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
|
|
3519
|
+
if (res.ok)
|
|
3520
|
+
landedCommits = ahead;
|
|
3521
|
+
return res;
|
|
3522
|
+
}),
|
|
3523
|
+
resolve: runResolver,
|
|
3524
|
+
maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
|
|
3525
|
+
emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
|
|
3526
|
+
label: `ticket ${ticket.seq}`,
|
|
3527
|
+
featureBranch: feature.branch
|
|
3528
|
+
};
|
|
3529
|
+
const landChain = async (branch, commits) => {
|
|
3530
|
+
const landing = await landWithResolve(branch, landDeps);
|
|
3531
|
+
if (landing.status === "landed") {
|
|
3532
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3533
|
+
return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
|
|
3534
|
+
}
|
|
3535
|
+
preserveChain(landing.branch);
|
|
3536
|
+
if (landing.status === "conflict") {
|
|
3537
|
+
ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
|
|
3538
|
+
const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
|
|
3539
|
+
const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
|
|
3540
|
+
return {
|
|
3541
|
+
status: "failed",
|
|
3542
|
+
error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
|
|
3543
|
+
event: {
|
|
3544
|
+
type: "merge.conflict.needs-human",
|
|
3545
|
+
message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
|
|
3546
|
+
}
|
|
3547
|
+
};
|
|
3548
|
+
}
|
|
3549
|
+
return {
|
|
3550
|
+
status: "failed",
|
|
3551
|
+
error: `ticket ${ticket.seq} committed to ${landing.branch} but landing on ${feature.branch} failed: ${errorHeadline(landing.error)} \u2014 the branch is preserved for manual recovery`
|
|
3552
|
+
};
|
|
3553
|
+
};
|
|
3350
3554
|
try {
|
|
3555
|
+
if (conflictResume) {
|
|
3556
|
+
const branch = ticket.attemptBranch;
|
|
3557
|
+
const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3558
|
+
if (pending.length === 0) {
|
|
3559
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3560
|
+
ctx.emitEvent({
|
|
3561
|
+
type: "merge.conflict.resolved",
|
|
3562
|
+
message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
|
|
3563
|
+
ticketId: ticket.id,
|
|
3564
|
+
data: { branch }
|
|
3565
|
+
});
|
|
3566
|
+
return { status: "done", commits: ticket.commits };
|
|
3567
|
+
}
|
|
3568
|
+
ctx.emitEvent({
|
|
3569
|
+
type: "ticket.resuming",
|
|
3570
|
+
message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
|
|
3571
|
+
ticketId: ticket.id,
|
|
3572
|
+
data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
|
|
3573
|
+
});
|
|
3574
|
+
return await landChain(branch, pending);
|
|
3575
|
+
}
|
|
3351
3576
|
let result;
|
|
3352
3577
|
let tempBranch = "";
|
|
3353
3578
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3403,12 +3628,16 @@ ${retryNotes}` : basePrompt,
|
|
|
3403
3628
|
};
|
|
3404
3629
|
}
|
|
3405
3630
|
if (isMergeConflictError(err)) {
|
|
3631
|
+
if (salvaged.length > 0) {
|
|
3632
|
+
preserveChain(tempBranch);
|
|
3633
|
+
ctx.updateTicket(ticket.id, { conflictFiles: [] });
|
|
3634
|
+
}
|
|
3406
3635
|
return {
|
|
3407
3636
|
status: "failed",
|
|
3408
3637
|
error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
|
|
3409
3638
|
event: {
|
|
3410
3639
|
type: "merge.conflict.needs-human",
|
|
3411
|
-
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
|
|
3640
|
+
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch}${salvaged.length > 0 ? " \u2014 resolve it from the run lane" : " \u2014 resolve manually per the error, then re-burn"}`
|
|
3412
3641
|
}
|
|
3413
3642
|
};
|
|
3414
3643
|
}
|
|
@@ -3466,28 +3695,7 @@ ${blocked.trim()}` };
|
|
|
3466
3695
|
preserveChain(tempBranch);
|
|
3467
3696
|
return outcome;
|
|
3468
3697
|
}
|
|
3469
|
-
|
|
3470
|
-
if (!merge.ok) {
|
|
3471
|
-
const detail = merge.error ?? "merge failed";
|
|
3472
|
-
if (merge.conflict) {
|
|
3473
|
-
return {
|
|
3474
|
-
status: "failed",
|
|
3475
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
3476
|
-
event: {
|
|
3477
|
-
type: "merge.conflict.needs-human",
|
|
3478
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3479
|
-
}
|
|
3480
|
-
};
|
|
3481
|
-
}
|
|
3482
|
-
preserveChain(tempBranch);
|
|
3483
|
-
return {
|
|
3484
|
-
status: "failed",
|
|
3485
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3486
|
-
};
|
|
3487
|
-
}
|
|
3488
|
-
if (ticket.attemptBranch)
|
|
3489
|
-
ctx.updateTicket(ticket.id, { attemptBranch: null });
|
|
3490
|
-
return outcome;
|
|
3698
|
+
return await landChain(tempBranch, outcome.commits);
|
|
3491
3699
|
} finally {
|
|
3492
3700
|
activeTicketAborts.delete(ticket.id);
|
|
3493
3701
|
throttle.flush();
|
|
@@ -3520,7 +3728,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3520
3728
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3521
3729
|
return token && token.length > 0 ? token : undefined;
|
|
3522
3730
|
}
|
|
3523
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
3731
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", 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;
|
|
3524
3732
|
var init_ticket_burner = __esm(() => {
|
|
3525
3733
|
init_src();
|
|
3526
3734
|
init_config_load();
|
|
@@ -3528,13 +3736,6 @@ var init_ticket_burner = __esm(() => {
|
|
|
3528
3736
|
init_skills_root();
|
|
3529
3737
|
init_agent_stream();
|
|
3530
3738
|
init_git();
|
|
3531
|
-
PLACEHOLDERS = [
|
|
3532
|
-
"TICKET_JSON",
|
|
3533
|
-
"FEATURE_BRIEF",
|
|
3534
|
-
"DOCS_DIGEST",
|
|
3535
|
-
"COMMIT_CONVENTION",
|
|
3536
|
-
"WORKSPACE_NOTES"
|
|
3537
|
-
];
|
|
3538
3739
|
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3539
3740
|
PM_CACHE_SANDBOX_PATHS = {
|
|
3540
3741
|
bun: "~/.bun/install/cache",
|
|
@@ -3625,7 +3826,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
|
|
|
3625
3826
|
}
|
|
3626
3827
|
function renderResearchPrompt(template, values) {
|
|
3627
3828
|
let out = template;
|
|
3628
|
-
for (const key of
|
|
3829
|
+
for (const key of PLACEHOLDERS) {
|
|
3629
3830
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
3630
3831
|
}
|
|
3631
3832
|
return out;
|
|
@@ -3727,7 +3928,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
3727
3928
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3728
3929
|
return token && token.length > 0 ? token : undefined;
|
|
3729
3930
|
}
|
|
3730
|
-
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
3931
|
+
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
|
|
3731
3932
|
var init_research = __esm(() => {
|
|
3732
3933
|
init_src();
|
|
3733
3934
|
init_config_load();
|
|
@@ -3735,7 +3936,7 @@ var init_research = __esm(() => {
|
|
|
3735
3936
|
init_skills_root();
|
|
3736
3937
|
init_git();
|
|
3737
3938
|
init_ticket_burner();
|
|
3738
|
-
|
|
3939
|
+
PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
3739
3940
|
research = {
|
|
3740
3941
|
id: "research",
|
|
3741
3942
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4075,24 +4276,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
|
|
|
4075
4276
|
preservedCommits = found.commits.length;
|
|
4076
4277
|
}
|
|
4077
4278
|
}
|
|
4279
|
+
const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
|
|
4078
4280
|
for (const t of toReset.values()) {
|
|
4079
4281
|
const isTarget = t.id === ticket.id;
|
|
4080
4282
|
updateTicket(ctx, t.id, {
|
|
4081
4283
|
status: "pending",
|
|
4082
4284
|
error: null,
|
|
4083
|
-
...isTarget ? { attemptBranch: resumedFrom } : {}
|
|
4285
|
+
...isTarget ? { attemptBranch: resumedFrom } : {},
|
|
4286
|
+
...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
|
|
4084
4287
|
});
|
|
4085
4288
|
}
|
|
4086
4289
|
const seqs = [...toReset.keys()].sort((a, b) => a - b);
|
|
4087
|
-
const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
4290
|
+
const how = opts.fresh ? " from scratch" : resolvingConflict ? ` \u2014 resolving its conflict with ${feature.branch} (${preservedCommits} commit(s) preserved on ${resumedFrom})` : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
4088
4291
|
emit(ctx, feature.id, {
|
|
4089
4292
|
type: "ticket.retry",
|
|
4090
4293
|
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}`,
|
|
4091
4294
|
ticketId: ticket.id,
|
|
4092
|
-
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
|
|
4295
|
+
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
|
|
4093
4296
|
});
|
|
4094
4297
|
const { runId } = await burn(ctx, feature.id, { resetFailed: false });
|
|
4095
|
-
return { runId, retried: seqs, resumedFrom, preservedCommits };
|
|
4298
|
+
return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
|
|
4096
4299
|
}
|
|
4097
4300
|
function escalateToMap(ctx, featureId, input) {
|
|
4098
4301
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -6053,6 +6256,14 @@ var init_settings = __esm(() => {
|
|
|
6053
6256
|
valueSchema: z9.number().int().min(1).max(5),
|
|
6054
6257
|
parseEnv: (raw) => Number(raw)
|
|
6055
6258
|
},
|
|
6259
|
+
{
|
|
6260
|
+
key: "burnConflictAttempts",
|
|
6261
|
+
configKey: "burnConflictAttempts",
|
|
6262
|
+
envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
|
|
6263
|
+
restartRequired: false,
|
|
6264
|
+
valueSchema: z9.number().int().min(0).max(3),
|
|
6265
|
+
parseEnv: (raw) => Number(raw)
|
|
6266
|
+
},
|
|
6056
6267
|
{
|
|
6057
6268
|
key: "setupCommand",
|
|
6058
6269
|
configKey: "setupCommand",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `tickets` ADD `conflict_files` text;
|