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/index.js
CHANGED
|
@@ -106,6 +106,7 @@ var init_config = __esm(() => {
|
|
|
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
108
|
burnAttempts: z.number().int().min(1).max(5).default(3),
|
|
109
|
+
burnConflictAttempts: z.number().int().min(0).max(3).default(2),
|
|
109
110
|
setupCommand: z.string().optional(),
|
|
110
111
|
burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
|
|
111
112
|
}));
|
|
@@ -149,6 +150,9 @@ function loadConfig(env = process.env) {
|
|
|
149
150
|
if (env.RUNCASTLE_BURN_ATTEMPTS) {
|
|
150
151
|
overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
|
|
151
152
|
}
|
|
153
|
+
if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
|
|
154
|
+
overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
|
|
155
|
+
}
|
|
152
156
|
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
153
157
|
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
154
158
|
if (env.RUNCASTLE_BURN_WORKSPACE)
|
|
@@ -215,7 +219,8 @@ var init_schemas = __esm(() => {
|
|
|
215
219
|
status: TicketStatus,
|
|
216
220
|
commits: z2.array(z2.string()),
|
|
217
221
|
error: z2.string().optional(),
|
|
218
|
-
attemptBranch: z2.string().optional()
|
|
222
|
+
attemptBranch: z2.string().optional(),
|
|
223
|
+
conflictFiles: z2.array(z2.string()).optional()
|
|
219
224
|
});
|
|
220
225
|
WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
|
|
221
226
|
WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
|
|
@@ -490,7 +495,8 @@ var init_db_schema = __esm(() => {
|
|
|
490
495
|
status: text("status").notNull().$type(),
|
|
491
496
|
commits: text("commits", { mode: "json" }).notNull().$type(),
|
|
492
497
|
error: text("error"),
|
|
493
|
-
attemptBranch: text("attempt_branch")
|
|
498
|
+
attemptBranch: text("attempt_branch"),
|
|
499
|
+
conflictFiles: text("conflict_files", { mode: "json" }).$type()
|
|
494
500
|
});
|
|
495
501
|
waypoints = sqliteTable("waypoints", {
|
|
496
502
|
id: text("id").primaryKey(),
|
|
@@ -1706,7 +1712,8 @@ function rowToTicket(row) {
|
|
|
1706
1712
|
status: row.status,
|
|
1707
1713
|
commits: row.commits,
|
|
1708
1714
|
error: row.error ?? undefined,
|
|
1709
|
-
attemptBranch: row.attemptBranch ?? undefined
|
|
1715
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
1716
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
1710
1717
|
};
|
|
1711
1718
|
}
|
|
1712
1719
|
function resolveBlocking(inputs, startSeq) {
|
|
@@ -1740,7 +1747,8 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1740
1747
|
status: "pending",
|
|
1741
1748
|
commits: [],
|
|
1742
1749
|
error: null,
|
|
1743
|
-
attemptBranch: null
|
|
1750
|
+
attemptBranch: null,
|
|
1751
|
+
conflictFiles: null
|
|
1744
1752
|
}));
|
|
1745
1753
|
ctx.db.insert(tickets).values(rows).run();
|
|
1746
1754
|
emit(ctx, featureId, {
|
|
@@ -1812,6 +1820,8 @@ function updateTicket(ctx, id, patch) {
|
|
|
1812
1820
|
set.error = patch.error;
|
|
1813
1821
|
if (patch.attemptBranch !== undefined)
|
|
1814
1822
|
set.attemptBranch = patch.attemptBranch;
|
|
1823
|
+
if (patch.conflictFiles !== undefined)
|
|
1824
|
+
set.conflictFiles = patch.conflictFiles;
|
|
1815
1825
|
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1816
1826
|
emit(ctx, current2.featureId, {
|
|
1817
1827
|
type: "ticket.updated",
|
|
@@ -2198,6 +2208,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2198
2208
|
return [];
|
|
2199
2209
|
}
|
|
2200
2210
|
}
|
|
2211
|
+
async function commitSummaries(repoPath, base, branch, limit = 20) {
|
|
2212
|
+
try {
|
|
2213
|
+
const out = await git(repoPath).raw([
|
|
2214
|
+
"log",
|
|
2215
|
+
`--max-count=${limit}`,
|
|
2216
|
+
"--format=%h %s",
|
|
2217
|
+
`${base}..${branch}`
|
|
2218
|
+
]);
|
|
2219
|
+
return out.split(`
|
|
2220
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
2221
|
+
} catch {
|
|
2222
|
+
return [];
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2201
2225
|
async function deleteTempBranch(repoPath, branch) {
|
|
2202
2226
|
try {
|
|
2203
2227
|
return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
@@ -2260,8 +2284,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
|
2260
2284
|
await gw.merge([tempBranch]);
|
|
2261
2285
|
} catch (e) {
|
|
2262
2286
|
if (await mergeInProgress(gw)) {
|
|
2287
|
+
const files = await conflictedFiles(gw);
|
|
2263
2288
|
await gw.raw(["merge", "--abort"]);
|
|
2264
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2289
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2265
2290
|
}
|
|
2266
2291
|
return { ok: false, error: errMsg(e) };
|
|
2267
2292
|
}
|
|
@@ -2292,8 +2317,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
|
|
|
2292
2317
|
await gw.merge([tempBranch]);
|
|
2293
2318
|
} catch (e) {
|
|
2294
2319
|
if (await mergeInProgress(gw)) {
|
|
2320
|
+
const files = await conflictedFiles(gw);
|
|
2295
2321
|
await gw.raw(["merge", "--abort"]);
|
|
2296
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2322
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2297
2323
|
}
|
|
2298
2324
|
return { ok: false, error: errMsg(e) };
|
|
2299
2325
|
}
|
|
@@ -2810,13 +2836,16 @@ function detectCycle(tickets2) {
|
|
|
2810
2836
|
}
|
|
2811
2837
|
return null;
|
|
2812
2838
|
}
|
|
2813
|
-
function
|
|
2839
|
+
function renderTemplate(template, values) {
|
|
2814
2840
|
let out = template;
|
|
2815
|
-
for (const key of
|
|
2816
|
-
out = out.split(`{{${key}}}`).join(
|
|
2841
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2842
|
+
out = out.split(`{{${key}}}`).join(value);
|
|
2817
2843
|
}
|
|
2818
2844
|
return out;
|
|
2819
2845
|
}
|
|
2846
|
+
function renderTicketPrompt(template, values) {
|
|
2847
|
+
return renderTemplate(template, values);
|
|
2848
|
+
}
|
|
2820
2849
|
function buildTicketJson(ticket) {
|
|
2821
2850
|
return JSON.stringify({
|
|
2822
2851
|
seq: ticket.seq,
|
|
@@ -2850,6 +2879,23 @@ ${f.content.trim()}`).join(`
|
|
|
2850
2879
|
|
|
2851
2880
|
`);
|
|
2852
2881
|
}
|
|
2882
|
+
function buildConflictFilesBlock(files) {
|
|
2883
|
+
if (files.length === 0) {
|
|
2884
|
+
return "_git did not report the paths \u2014 run `git status` after starting the merge to see them._";
|
|
2885
|
+
}
|
|
2886
|
+
return files.map((f) => `- \`${f}\``).join(`
|
|
2887
|
+
`);
|
|
2888
|
+
}
|
|
2889
|
+
function buildOtherSideBlock(summaries) {
|
|
2890
|
+
if (summaries.length === 0) {
|
|
2891
|
+
return "_No commit summaries available \u2014 inspect `git log` on the feature branch._";
|
|
2892
|
+
}
|
|
2893
|
+
return summaries.map((s) => `- ${s}`).join(`
|
|
2894
|
+
`);
|
|
2895
|
+
}
|
|
2896
|
+
function resolveMergeCommand(mode, featureBranch2) {
|
|
2897
|
+
return mode === "mounted" ? `git merge --no-edit ${featureBranch2}` : `git fetch origin ${featureBranch2} && git merge --no-edit FETCH_HEAD`;
|
|
2898
|
+
}
|
|
2853
2899
|
function parseEnvFile(content) {
|
|
2854
2900
|
const out = {};
|
|
2855
2901
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
@@ -3074,6 +3120,42 @@ function createSerialQueue() {
|
|
|
3074
3120
|
return next;
|
|
3075
3121
|
};
|
|
3076
3122
|
}
|
|
3123
|
+
async function landWithResolve(branch, deps) {
|
|
3124
|
+
let current2 = branch;
|
|
3125
|
+
for (let attempt = 1;; attempt++) {
|
|
3126
|
+
const merge = await deps.merge(current2);
|
|
3127
|
+
if (merge.ok)
|
|
3128
|
+
return { status: "landed", branch: current2 };
|
|
3129
|
+
const detail = merge.error ?? "merge failed";
|
|
3130
|
+
if (!merge.conflict)
|
|
3131
|
+
return { status: "failed", branch: current2, error: detail };
|
|
3132
|
+
const files = merge.files ?? [];
|
|
3133
|
+
if (attempt > deps.maxResolveAttempts) {
|
|
3134
|
+
return { status: "conflict", branch: current2, files, error: detail };
|
|
3135
|
+
}
|
|
3136
|
+
const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
|
|
3137
|
+
deps.emit({
|
|
3138
|
+
type: "merge.conflict.resolving",
|
|
3139
|
+
message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
|
|
3140
|
+
data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
|
|
3141
|
+
});
|
|
3142
|
+
const resolved = await deps.resolve({
|
|
3143
|
+
branch: current2,
|
|
3144
|
+
files,
|
|
3145
|
+
attempt,
|
|
3146
|
+
maxAttempts: deps.maxResolveAttempts
|
|
3147
|
+
});
|
|
3148
|
+
current2 = resolved.branch;
|
|
3149
|
+
if (!resolved.ok) {
|
|
3150
|
+
return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
|
|
3151
|
+
}
|
|
3152
|
+
deps.emit({
|
|
3153
|
+
type: "merge.conflict.resolved",
|
|
3154
|
+
message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
|
|
3155
|
+
data: { branch: current2, files, attempt }
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3077
3159
|
function errorHeadline(s) {
|
|
3078
3160
|
const lines = s.split(`
|
|
3079
3161
|
`).map((l) => l.trim()).filter(Boolean);
|
|
@@ -3221,8 +3303,14 @@ async function burnRun(ctx, deps) {
|
|
|
3221
3303
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
3222
3304
|
}
|
|
3223
3305
|
function burnerTemplatePath() {
|
|
3306
|
+
return burnerAssetPath("implement-ticket.md");
|
|
3307
|
+
}
|
|
3308
|
+
function resolverTemplatePath() {
|
|
3309
|
+
return burnerAssetPath("resolve-conflict.md");
|
|
3310
|
+
}
|
|
3311
|
+
function burnerAssetPath(file) {
|
|
3224
3312
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3225
|
-
return join9(resolveSkillsRoot(here), "burner",
|
|
3313
|
+
return join9(resolveSkillsRoot(here), "burner", file);
|
|
3226
3314
|
}
|
|
3227
3315
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3228
3316
|
const worktree = worktreeDir(projectId, slug);
|
|
@@ -3286,13 +3374,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3286
3374
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3287
3375
|
if (workspaceMode === "isolated")
|
|
3288
3376
|
await ensureIsolatedPushTarget();
|
|
3289
|
-
const
|
|
3290
|
-
const
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3377
|
+
const ticketJson = buildTicketJson(ticket);
|
|
3378
|
+
const featureBrief = buildFeatureBrief(feature);
|
|
3379
|
+
const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
|
|
3380
|
+
const workspaceNotes = buildWorkspaceNotes(workspaceMode);
|
|
3381
|
+
const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
|
|
3382
|
+
TICKET_JSON: ticketJson,
|
|
3383
|
+
FEATURE_BRIEF: featureBrief,
|
|
3384
|
+
DOCS_DIGEST: docsDigest,
|
|
3294
3385
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
|
|
3295
|
-
WORKSPACE_NOTES:
|
|
3386
|
+
WORKSPACE_NOTES: workspaceNotes
|
|
3296
3387
|
});
|
|
3297
3388
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3298
3389
|
const pm = detectPackageManager(toolchain);
|
|
@@ -3325,9 +3416,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3325
3416
|
activeTicketAborts.set(ticket.id, ticketAbort);
|
|
3326
3417
|
const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
|
|
3327
3418
|
const maxAttempts = Math.max(1, config2.burnAttempts);
|
|
3419
|
+
const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
|
|
3328
3420
|
let baseBranch = feature.branch;
|
|
3329
3421
|
let retryNotes;
|
|
3330
|
-
if (ticket.attemptBranch) {
|
|
3422
|
+
if (ticket.attemptBranch && !conflictResume) {
|
|
3331
3423
|
const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
|
|
3332
3424
|
if (preserved.length > 0) {
|
|
3333
3425
|
baseBranch = ticket.attemptBranch;
|
|
@@ -3346,7 +3438,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3346
3438
|
const preserveChain = (branch) => {
|
|
3347
3439
|
ctx.updateTicket(ticket.id, { attemptBranch: branch });
|
|
3348
3440
|
};
|
|
3441
|
+
const runResolver = async (input) => {
|
|
3442
|
+
const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
|
|
3443
|
+
const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
|
|
3444
|
+
const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
|
|
3445
|
+
TICKET_JSON: ticketJson,
|
|
3446
|
+
FEATURE_BRIEF: featureBrief,
|
|
3447
|
+
DOCS_DIGEST: docsDigest,
|
|
3448
|
+
WORKSPACE_NOTES: workspaceNotes,
|
|
3449
|
+
FEATURE_BRANCH: feature.branch,
|
|
3450
|
+
CONFLICT_FILES: buildConflictFilesBlock(input.files),
|
|
3451
|
+
OTHER_SIDE: buildOtherSideBlock(otherSide),
|
|
3452
|
+
MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
|
|
3453
|
+
});
|
|
3454
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
|
|
3455
|
+
appendTranscript(ticket.id, {
|
|
3456
|
+
kind: "text",
|
|
3457
|
+
text: `
|
|
3458
|
+
\u2014 landing conflict on ${feature.branch}; resolver pass ${input.attempt}/${input.maxAttempts} \u2014
|
|
3459
|
+
`
|
|
3460
|
+
});
|
|
3461
|
+
const bestTip = async () => {
|
|
3462
|
+
const made = await branchCommitsAhead(project.repoPath, input.branch, resolveBranch);
|
|
3463
|
+
return made.length > 0 ? resolveBranch : input.branch;
|
|
3464
|
+
};
|
|
3465
|
+
let result;
|
|
3466
|
+
try {
|
|
3467
|
+
result = await run({
|
|
3468
|
+
agent: buildBurnAgent(config2, token, model),
|
|
3469
|
+
sandbox: selectSandbox(config2, mounts),
|
|
3470
|
+
cwd: project.repoPath,
|
|
3471
|
+
prompt,
|
|
3472
|
+
branchStrategy: { type: "branch", branch: resolveBranch, baseBranch: input.branch },
|
|
3473
|
+
signal,
|
|
3474
|
+
name: `ticket-${ticket.seq}-resolve`,
|
|
3475
|
+
maxIterations: config2.burnMaxIterations,
|
|
3476
|
+
...hookCommand ? {
|
|
3477
|
+
hooks: {
|
|
3478
|
+
sandbox: {
|
|
3479
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
} : {},
|
|
3483
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
3484
|
+
});
|
|
3485
|
+
} catch (err) {
|
|
3486
|
+
if (ctx.signal.aborted)
|
|
3487
|
+
throw err;
|
|
3488
|
+
return {
|
|
3489
|
+
ok: false,
|
|
3490
|
+
branch: await bestTip(),
|
|
3491
|
+
error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
3494
|
+
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
3495
|
+
if (blocked !== undefined && blocked.trim().length > 0) {
|
|
3496
|
+
return {
|
|
3497
|
+
ok: false,
|
|
3498
|
+
branch: await bestTip(),
|
|
3499
|
+
error: `resolver reported BLOCKED:
|
|
3500
|
+
${blocked.trim()}`
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
const missing = await branchCommitsAhead(project.repoPath, resolveBranch, feature.branch);
|
|
3504
|
+
if (missing.length > 0) {
|
|
3505
|
+
return {
|
|
3506
|
+
ok: false,
|
|
3507
|
+
branch: await bestTip(),
|
|
3508
|
+
error: `resolver did not complete the merge \u2014 ${missing.length} commit(s) of ${feature.branch} are still missing from the branch`
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
return { ok: true, branch: resolveBranch };
|
|
3512
|
+
};
|
|
3513
|
+
let landedCommits = [];
|
|
3514
|
+
const landDeps = {
|
|
3515
|
+
merge: (branch) => land(async () => {
|
|
3516
|
+
const ahead = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3517
|
+
const res = await mergeTempBranch(project.repoPath, feature.branch, branch);
|
|
3518
|
+
if (res.ok)
|
|
3519
|
+
landedCommits = ahead;
|
|
3520
|
+
return res;
|
|
3521
|
+
}),
|
|
3522
|
+
resolve: runResolver,
|
|
3523
|
+
maxResolveAttempts: Math.max(0, config2.burnConflictAttempts),
|
|
3524
|
+
emit: (e) => ctx.emitEvent({ ...e, ticketId: ticket.id }),
|
|
3525
|
+
label: `ticket ${ticket.seq}`,
|
|
3526
|
+
featureBranch: feature.branch
|
|
3527
|
+
};
|
|
3528
|
+
const landChain = async (branch, commits) => {
|
|
3529
|
+
const landing = await landWithResolve(branch, landDeps);
|
|
3530
|
+
if (landing.status === "landed") {
|
|
3531
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3532
|
+
return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
|
|
3533
|
+
}
|
|
3534
|
+
preserveChain(landing.branch);
|
|
3535
|
+
if (landing.status === "conflict") {
|
|
3536
|
+
ctx.updateTicket(ticket.id, { conflictFiles: landing.files });
|
|
3537
|
+
const where = landing.files.length > 0 ? ` on ${landing.files.length} file(s): ${landing.files.join(", ")}` : "";
|
|
3538
|
+
const why = landDeps.maxResolveAttempts === 0 ? `Automatic resolution is off (burnConflictAttempts = 0): ${errorHeadline(landing.error)}` : `The automatic resolver could not finish: ${errorHeadline(landing.error)}`;
|
|
3539
|
+
return {
|
|
3540
|
+
status: "failed",
|
|
3541
|
+
error: `ticket ${ticket.seq} is implemented on ${landing.branch} but conflicts with ${feature.branch}${where}. ${why}`,
|
|
3542
|
+
event: {
|
|
3543
|
+
type: "merge.conflict.needs-human",
|
|
3544
|
+
message: `ticket ${ticket.seq}: could not auto-resolve the conflict with ${feature.branch} \u2014 resolve it from the run lane`
|
|
3545
|
+
}
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
return {
|
|
3549
|
+
status: "failed",
|
|
3550
|
+
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`
|
|
3551
|
+
};
|
|
3552
|
+
};
|
|
3349
3553
|
try {
|
|
3554
|
+
if (conflictResume) {
|
|
3555
|
+
const branch = ticket.attemptBranch;
|
|
3556
|
+
const pending = await branchCommitsAhead(project.repoPath, feature.branch, branch);
|
|
3557
|
+
if (pending.length === 0) {
|
|
3558
|
+
ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
|
|
3559
|
+
ctx.emitEvent({
|
|
3560
|
+
type: "merge.conflict.resolved",
|
|
3561
|
+
message: `ticket ${ticket.seq}: ${branch} is already merged into ${feature.branch} \u2014 nothing left to land`,
|
|
3562
|
+
ticketId: ticket.id,
|
|
3563
|
+
data: { branch }
|
|
3564
|
+
});
|
|
3565
|
+
return { status: "done", commits: ticket.commits };
|
|
3566
|
+
}
|
|
3567
|
+
ctx.emitEvent({
|
|
3568
|
+
type: "ticket.resuming",
|
|
3569
|
+
message: `ticket ${ticket.seq}: implemented on ${branch} \u2014 retrying the landing (${pending.length} commit(s))`,
|
|
3570
|
+
ticketId: ticket.id,
|
|
3571
|
+
data: { attemptBranch: branch, preservedCommits: pending.length, conflict: true }
|
|
3572
|
+
});
|
|
3573
|
+
return await landChain(branch, pending);
|
|
3574
|
+
}
|
|
3350
3575
|
let result;
|
|
3351
3576
|
let tempBranch = "";
|
|
3352
3577
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3402,12 +3627,16 @@ ${retryNotes}` : basePrompt,
|
|
|
3402
3627
|
};
|
|
3403
3628
|
}
|
|
3404
3629
|
if (isMergeConflictError(err)) {
|
|
3630
|
+
if (salvaged.length > 0) {
|
|
3631
|
+
preserveChain(tempBranch);
|
|
3632
|
+
ctx.updateTicket(ticket.id, { conflictFiles: [] });
|
|
3633
|
+
}
|
|
3405
3634
|
return {
|
|
3406
3635
|
status: "failed",
|
|
3407
3636
|
error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
|
|
3408
3637
|
event: {
|
|
3409
3638
|
type: "merge.conflict.needs-human",
|
|
3410
|
-
message: `ticket ${ticket.seq}: merge conflict on ${feature.branch} \u2014 resolve manually per the error, then re-burn`
|
|
3639
|
+
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"}`
|
|
3411
3640
|
}
|
|
3412
3641
|
};
|
|
3413
3642
|
}
|
|
@@ -3465,28 +3694,7 @@ ${blocked.trim()}` };
|
|
|
3465
3694
|
preserveChain(tempBranch);
|
|
3466
3695
|
return outcome;
|
|
3467
3696
|
}
|
|
3468
|
-
|
|
3469
|
-
if (!merge.ok) {
|
|
3470
|
-
const detail = merge.error ?? "merge failed";
|
|
3471
|
-
if (merge.conflict) {
|
|
3472
|
-
return {
|
|
3473
|
-
status: "failed",
|
|
3474
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
3475
|
-
event: {
|
|
3476
|
-
type: "merge.conflict.needs-human",
|
|
3477
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3478
|
-
}
|
|
3479
|
-
};
|
|
3480
|
-
}
|
|
3481
|
-
preserveChain(tempBranch);
|
|
3482
|
-
return {
|
|
3483
|
-
status: "failed",
|
|
3484
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3485
|
-
};
|
|
3486
|
-
}
|
|
3487
|
-
if (ticket.attemptBranch)
|
|
3488
|
-
ctx.updateTicket(ticket.id, { attemptBranch: null });
|
|
3489
|
-
return outcome;
|
|
3697
|
+
return await landChain(tempBranch, outcome.commits);
|
|
3490
3698
|
} finally {
|
|
3491
3699
|
activeTicketAborts.delete(ticket.id);
|
|
3492
3700
|
throttle.flush();
|
|
@@ -3519,7 +3727,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3519
3727
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3520
3728
|
return token && token.length > 0 ? token : undefined;
|
|
3521
3729
|
}
|
|
3522
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
3730
|
+
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;
|
|
3523
3731
|
var init_ticket_burner = __esm(() => {
|
|
3524
3732
|
init_src();
|
|
3525
3733
|
init_config_load();
|
|
@@ -3527,13 +3735,6 @@ var init_ticket_burner = __esm(() => {
|
|
|
3527
3735
|
init_skills_root();
|
|
3528
3736
|
init_agent_stream();
|
|
3529
3737
|
init_git();
|
|
3530
|
-
PLACEHOLDERS = [
|
|
3531
|
-
"TICKET_JSON",
|
|
3532
|
-
"FEATURE_BRIEF",
|
|
3533
|
-
"DOCS_DIGEST",
|
|
3534
|
-
"COMMIT_CONVENTION",
|
|
3535
|
-
"WORKSPACE_NOTES"
|
|
3536
|
-
];
|
|
3537
3738
|
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3538
3739
|
PM_CACHE_SANDBOX_PATHS = {
|
|
3539
3740
|
bun: "~/.bun/install/cache",
|
|
@@ -3624,7 +3825,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
|
|
|
3624
3825
|
}
|
|
3625
3826
|
function renderResearchPrompt(template, values) {
|
|
3626
3827
|
let out = template;
|
|
3627
|
-
for (const key of
|
|
3828
|
+
for (const key of PLACEHOLDERS) {
|
|
3628
3829
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
3629
3830
|
}
|
|
3630
3831
|
return out;
|
|
@@ -3726,7 +3927,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
3726
3927
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3727
3928
|
return token && token.length > 0 ? token : undefined;
|
|
3728
3929
|
}
|
|
3729
|
-
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env",
|
|
3930
|
+
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
|
|
3730
3931
|
var init_research = __esm(() => {
|
|
3731
3932
|
init_src();
|
|
3732
3933
|
init_config_load();
|
|
@@ -3734,7 +3935,7 @@ var init_research = __esm(() => {
|
|
|
3734
3935
|
init_skills_root();
|
|
3735
3936
|
init_git();
|
|
3736
3937
|
init_ticket_burner();
|
|
3737
|
-
|
|
3938
|
+
PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
3738
3939
|
research = {
|
|
3739
3940
|
id: "research",
|
|
3740
3941
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4074,24 +4275,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
|
|
|
4074
4275
|
preservedCommits = found.commits.length;
|
|
4075
4276
|
}
|
|
4076
4277
|
}
|
|
4278
|
+
const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
|
|
4077
4279
|
for (const t of toReset.values()) {
|
|
4078
4280
|
const isTarget = t.id === ticket.id;
|
|
4079
4281
|
updateTicket(ctx, t.id, {
|
|
4080
4282
|
status: "pending",
|
|
4081
4283
|
error: null,
|
|
4082
|
-
...isTarget ? { attemptBranch: resumedFrom } : {}
|
|
4284
|
+
...isTarget ? { attemptBranch: resumedFrom } : {},
|
|
4285
|
+
...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
|
|
4083
4286
|
});
|
|
4084
4287
|
}
|
|
4085
4288
|
const seqs = [...toReset.keys()].sort((a, b) => a - b);
|
|
4086
|
-
const how = opts.fresh ? " from scratch" : resumedFrom ? ` from ${preservedCommits} preserved commit(s) on ${resumedFrom}` : "";
|
|
4289
|
+
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}` : "";
|
|
4087
4290
|
emit(ctx, feature.id, {
|
|
4088
4291
|
type: "ticket.retry",
|
|
4089
4292
|
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}`,
|
|
4090
4293
|
ticketId: ticket.id,
|
|
4091
|
-
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
|
|
4294
|
+
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
|
|
4092
4295
|
});
|
|
4093
4296
|
const { runId } = await burn(ctx, feature.id, { resetFailed: false });
|
|
4094
|
-
return { runId, retried: seqs, resumedFrom, preservedCommits };
|
|
4297
|
+
return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
|
|
4095
4298
|
}
|
|
4096
4299
|
function escalateToMap(ctx, featureId, input) {
|
|
4097
4300
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -6052,6 +6255,14 @@ var init_settings = __esm(() => {
|
|
|
6052
6255
|
valueSchema: z9.number().int().min(1).max(5),
|
|
6053
6256
|
parseEnv: (raw) => Number(raw)
|
|
6054
6257
|
},
|
|
6258
|
+
{
|
|
6259
|
+
key: "burnConflictAttempts",
|
|
6260
|
+
configKey: "burnConflictAttempts",
|
|
6261
|
+
envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
|
|
6262
|
+
restartRequired: false,
|
|
6263
|
+
valueSchema: z9.number().int().min(0).max(3),
|
|
6264
|
+
parseEnv: (raw) => Number(raw)
|
|
6265
|
+
},
|
|
6055
6266
|
{
|
|
6056
6267
|
key: "setupCommand",
|
|
6057
6268
|
configKey: "setupCommand",
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<!-- Rendered by the ticket-burner when a ticket's branch fails to land on the feature branch. Placeholder tokens are filled in by the workflow. -->
|
|
2
|
+
|
|
3
|
+
# Resolve a landing conflict — unattended
|
|
4
|
+
|
|
5
|
+
You already implemented a ticket. Your commits are on your branch, but they could **not** be landed on the feature branch: while you were working, sibling tickets landed their own commits and you both touched the same files.
|
|
6
|
+
|
|
7
|
+
Your one job now is to **merge the feature branch into your branch and resolve the conflicts**, so your work and theirs both survive. You are not re-implementing anything, and you are not starting new work.
|
|
8
|
+
|
|
9
|
+
There is **no human to ask** — everything you need is in this prompt and in the repo. This is the last automated attempt before a human is pulled in, so be careful, not fast.
|
|
10
|
+
|
|
11
|
+
## How you run
|
|
12
|
+
|
|
13
|
+
You run **non-interactively** (`claude --print`), for up to a few fresh iterations against the same worktree:
|
|
14
|
+
|
|
15
|
+
- **Ending your turn ends your process.** There are no background-task completion notifications in print mode — a "the notification will re-invoke me" plan never fires. Run long commands (test suites) in the foreground with a generous timeout.
|
|
16
|
+
- **Signal completion.** When the merge commit is in, print exactly `<promise>COMPLETE</promise>` as the last line of your final message. Do the same after writing `BLOCKED.md` (see hard rules).
|
|
17
|
+
|
|
18
|
+
## Where to work
|
|
19
|
+
|
|
20
|
+
{{WORKSPACE_NOTES}}
|
|
21
|
+
|
|
22
|
+
## The conflict
|
|
23
|
+
|
|
24
|
+
Merging your branch into `{{FEATURE_BRANCH}}` conflicts on:
|
|
25
|
+
|
|
26
|
+
{{CONFLICT_FILES}}
|
|
27
|
+
|
|
28
|
+
These commits landed on `{{FEATURE_BRANCH}}` while you were working — this is the other side of the merge, the work you must preserve alongside your own:
|
|
29
|
+
|
|
30
|
+
{{OTHER_SIDE}}
|
|
31
|
+
|
|
32
|
+
## The ticket you implemented
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{{TICKET_JSON}}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Feature context
|
|
39
|
+
|
|
40
|
+
{{FEATURE_BRIEF}}
|
|
41
|
+
|
|
42
|
+
## Feature docs (spec / decisions digest)
|
|
43
|
+
|
|
44
|
+
{{DOCS_DIGEST}}
|
|
45
|
+
|
|
46
|
+
## How to work
|
|
47
|
+
|
|
48
|
+
1. **Orient before touching anything.** `git log --oneline -15` to see your own commits, then read the conflicting files. Understand what *your* commits were doing there (the ticket above is the intent) and what the *other* side's commits were doing (the log above, plus `git show` on them).
|
|
49
|
+
|
|
50
|
+
2. **Start the merge:**
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
{{MERGE_COMMAND}}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It will stop with conflicts. `git status` lists the unmerged paths.
|
|
57
|
+
|
|
58
|
+
3. **Resolve by intent, not by picking a side.** For every `<<<<<<< / ======= / >>>>>>>` hunk, keep *both* behaviours working. Reconcile the logic where the two sides genuinely overlap — the feature docs above are the tie-breaker on intent. Never delete one side's behaviour just to make the conflict go away, and never resolve by taking your whole side (`--ours`) or theirs (`--theirs`) wholesale unless one side is genuinely a strict superset.
|
|
59
|
+
|
|
60
|
+
4. **Watch for semantic conflicts, not just textual ones.** Files that merged cleanly can still be broken by the other side's rename, signature change, or moved export. After resolving the marked hunks, run typecheck and the tests covering both sides' touched code, and fix what the merge broke.
|
|
61
|
+
|
|
62
|
+
5. **Complete the merge.** Stage the resolved files and commit — keep it a merge commit (`git commit --no-verify`, do not `--squash`, do not rebase, do not amend your earlier commits). A merge commit whose message names what was reconciled is ideal.
|
|
63
|
+
|
|
64
|
+
## Hard rules
|
|
65
|
+
|
|
66
|
+
- **Never expand scope.** Resolving the conflict is the whole job. No refactors, no improvements, no adjacent fixes, not even tempting ones in the files you are already editing.
|
|
67
|
+
- **Never lose committed work.** Not yours, not theirs. `git merge --abort` + "start over from one side" is not a resolution.
|
|
68
|
+
- **Never push, and never touch the remote.** The orchestrator lands your branch itself once the merge commit exists.
|
|
69
|
+
- **If the two sides genuinely cannot be reconciled without a product decision** (they implement contradictory behaviour, not merely overlapping code): `git merge --abort`, write `BLOCKED.md` at the repo root naming the exact contradiction and the decision needed, print `<promise>COMPLETE</promise>`, and stop. A human will decide. Do not guess.
|