runcastle 1.0.20 → 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 +357 -60
- 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 +357 -60
- package/package.json +1 -1
- package/skills/burner/resolve-conflict.md +69 -0
- package/web/assets/{index-CnpBTT2-.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
|
@@ -72,7 +72,7 @@ function resolveSandboxImage(config) {
|
|
|
72
72
|
function resolveModel(step, config, project, runOverride) {
|
|
73
73
|
return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
|
|
74
74
|
}
|
|
75
|
-
var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5
|
|
75
|
+
var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5", foldLegacyModelConfig = (raw) => {
|
|
76
76
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
77
77
|
return raw;
|
|
78
78
|
const obj = { ...raw };
|
|
@@ -99,7 +99,7 @@ var init_config = __esm(() => {
|
|
|
99
99
|
ModelStep = z.enum(MODEL_STEPS);
|
|
100
100
|
RuncastleConfig = z.preprocess(foldLegacyModelConfig, z.object({
|
|
101
101
|
serverPort: z.number().default(4512),
|
|
102
|
-
model: z.string().default("claude-opus-
|
|
102
|
+
model: z.string().default("claude-opus-5"),
|
|
103
103
|
stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
|
|
104
104
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
105
105
|
mainBranch: z.string().default("main"),
|
|
@@ -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(),
|
|
@@ -1127,6 +1133,27 @@ var init_errors = __esm(() => {
|
|
|
1127
1133
|
};
|
|
1128
1134
|
});
|
|
1129
1135
|
|
|
1136
|
+
// src/services/bus.ts
|
|
1137
|
+
function subscribeLive(fn) {
|
|
1138
|
+
subscribers.add(fn);
|
|
1139
|
+
return () => {
|
|
1140
|
+
subscribers.delete(fn);
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function publishLive(signal) {
|
|
1144
|
+
for (const fn of [...subscribers]) {
|
|
1145
|
+
try {
|
|
1146
|
+
fn(signal);
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
console.error("live bus subscriber threw", err);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
var subscribers;
|
|
1153
|
+
var init_bus = __esm(() => {
|
|
1154
|
+
subscribers = new Set;
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1130
1157
|
// src/services/events.ts
|
|
1131
1158
|
import { and, asc, eq, gt } from "drizzle-orm";
|
|
1132
1159
|
function rowToEvent(row) {
|
|
@@ -1165,7 +1192,14 @@ function insertEvent(ctx, projectId, featureId, e) {
|
|
|
1165
1192
|
message: e.message,
|
|
1166
1193
|
data: e.data ?? null
|
|
1167
1194
|
}).returning().get();
|
|
1168
|
-
|
|
1195
|
+
const event = rowToEvent(row);
|
|
1196
|
+
publishLive({
|
|
1197
|
+
kind: "event",
|
|
1198
|
+
projectId: event.projectId,
|
|
1199
|
+
featureId: event.featureId,
|
|
1200
|
+
eventId: event.id
|
|
1201
|
+
});
|
|
1202
|
+
return event;
|
|
1169
1203
|
}
|
|
1170
1204
|
function listAfter(ctx, featureId, afterId = 0) {
|
|
1171
1205
|
const rows = ctx.db.select().from(events).where(and(eq(events.featureId, featureId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
|
|
@@ -1178,6 +1212,7 @@ function listByProject(ctx, projectId, afterId = 0) {
|
|
|
1178
1212
|
var init_events = __esm(() => {
|
|
1179
1213
|
init_schema();
|
|
1180
1214
|
init_errors();
|
|
1215
|
+
init_bus();
|
|
1181
1216
|
});
|
|
1182
1217
|
|
|
1183
1218
|
// src/services/repo.ts
|
|
@@ -1678,7 +1713,8 @@ function rowToTicket(row) {
|
|
|
1678
1713
|
status: row.status,
|
|
1679
1714
|
commits: row.commits,
|
|
1680
1715
|
error: row.error ?? undefined,
|
|
1681
|
-
attemptBranch: row.attemptBranch ?? undefined
|
|
1716
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
1717
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
1682
1718
|
};
|
|
1683
1719
|
}
|
|
1684
1720
|
function resolveBlocking(inputs, startSeq) {
|
|
@@ -1712,7 +1748,8 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1712
1748
|
status: "pending",
|
|
1713
1749
|
commits: [],
|
|
1714
1750
|
error: null,
|
|
1715
|
-
attemptBranch: null
|
|
1751
|
+
attemptBranch: null,
|
|
1752
|
+
conflictFiles: null
|
|
1716
1753
|
}));
|
|
1717
1754
|
ctx.db.insert(tickets).values(rows).run();
|
|
1718
1755
|
emit(ctx, featureId, {
|
|
@@ -1784,6 +1821,8 @@ function updateTicket(ctx, id, patch) {
|
|
|
1784
1821
|
set.error = patch.error;
|
|
1785
1822
|
if (patch.attemptBranch !== undefined)
|
|
1786
1823
|
set.attemptBranch = patch.attemptBranch;
|
|
1824
|
+
if (patch.conflictFiles !== undefined)
|
|
1825
|
+
set.conflictFiles = patch.conflictFiles;
|
|
1787
1826
|
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1788
1827
|
emit(ctx, current2.featureId, {
|
|
1789
1828
|
type: "ticket.updated",
|
|
@@ -2170,6 +2209,20 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2170
2209
|
return [];
|
|
2171
2210
|
}
|
|
2172
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
|
+
}
|
|
2173
2226
|
async function deleteTempBranch(repoPath, branch) {
|
|
2174
2227
|
try {
|
|
2175
2228
|
return await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
@@ -2232,8 +2285,9 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
|
2232
2285
|
await gw.merge([tempBranch]);
|
|
2233
2286
|
} catch (e) {
|
|
2234
2287
|
if (await mergeInProgress(gw)) {
|
|
2288
|
+
const files = await conflictedFiles(gw);
|
|
2235
2289
|
await gw.raw(["merge", "--abort"]);
|
|
2236
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2290
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2237
2291
|
}
|
|
2238
2292
|
return { ok: false, error: errMsg(e) };
|
|
2239
2293
|
}
|
|
@@ -2264,8 +2318,9 @@ async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
|
|
|
2264
2318
|
await gw.merge([tempBranch]);
|
|
2265
2319
|
} catch (e) {
|
|
2266
2320
|
if (await mergeInProgress(gw)) {
|
|
2321
|
+
const files = await conflictedFiles(gw);
|
|
2267
2322
|
await gw.raw(["merge", "--abort"]);
|
|
2268
|
-
return { ok: false, conflict: true, error: errMsg(e) };
|
|
2323
|
+
return { ok: false, conflict: true, files, error: errMsg(e) };
|
|
2269
2324
|
}
|
|
2270
2325
|
return { ok: false, error: errMsg(e) };
|
|
2271
2326
|
}
|
|
@@ -2690,6 +2745,7 @@ var init_skills_root = __esm(() => {
|
|
|
2690
2745
|
// src/services/agent-stream.ts
|
|
2691
2746
|
function beginTranscript(ticketId) {
|
|
2692
2747
|
transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
|
|
2748
|
+
publishLive({ kind: "transcript", ticketId });
|
|
2693
2749
|
}
|
|
2694
2750
|
function appendTranscript(ticketId, chunk) {
|
|
2695
2751
|
const t = transcripts.get(ticketId);
|
|
@@ -2705,11 +2761,13 @@ function appendTranscript(ticketId, chunk) {
|
|
|
2705
2761
|
break;
|
|
2706
2762
|
t.bytes -= dropped.text.length;
|
|
2707
2763
|
}
|
|
2764
|
+
publishLive({ kind: "transcript", ticketId });
|
|
2708
2765
|
}
|
|
2709
2766
|
function endTranscript(ticketId) {
|
|
2710
2767
|
const t = transcripts.get(ticketId);
|
|
2711
2768
|
if (t)
|
|
2712
2769
|
t.live = false;
|
|
2770
|
+
publishLive({ kind: "transcript", ticketId });
|
|
2713
2771
|
}
|
|
2714
2772
|
function readTranscript(ticketId, after = -1) {
|
|
2715
2773
|
const t = transcripts.get(ticketId);
|
|
@@ -2724,6 +2782,7 @@ function readTranscript(ticketId, after = -1) {
|
|
|
2724
2782
|
}
|
|
2725
2783
|
var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
|
|
2726
2784
|
var init_agent_stream = __esm(() => {
|
|
2785
|
+
init_bus();
|
|
2727
2786
|
transcripts = new Map;
|
|
2728
2787
|
});
|
|
2729
2788
|
|
|
@@ -2778,13 +2837,16 @@ function detectCycle(tickets2) {
|
|
|
2778
2837
|
}
|
|
2779
2838
|
return null;
|
|
2780
2839
|
}
|
|
2781
|
-
function
|
|
2840
|
+
function renderTemplate(template, values) {
|
|
2782
2841
|
let out = template;
|
|
2783
|
-
for (const key of
|
|
2784
|
-
out = out.split(`{{${key}}}`).join(
|
|
2842
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2843
|
+
out = out.split(`{{${key}}}`).join(value);
|
|
2785
2844
|
}
|
|
2786
2845
|
return out;
|
|
2787
2846
|
}
|
|
2847
|
+
function renderTicketPrompt(template, values) {
|
|
2848
|
+
return renderTemplate(template, values);
|
|
2849
|
+
}
|
|
2788
2850
|
function buildTicketJson(ticket) {
|
|
2789
2851
|
return JSON.stringify({
|
|
2790
2852
|
seq: ticket.seq,
|
|
@@ -2818,6 +2880,23 @@ ${f.content.trim()}`).join(`
|
|
|
2818
2880
|
|
|
2819
2881
|
`);
|
|
2820
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
|
+
}
|
|
2821
2900
|
function parseEnvFile(content) {
|
|
2822
2901
|
const out = {};
|
|
2823
2902
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
@@ -3042,6 +3121,42 @@ function createSerialQueue() {
|
|
|
3042
3121
|
return next;
|
|
3043
3122
|
};
|
|
3044
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
|
+
}
|
|
3045
3160
|
function errorHeadline(s) {
|
|
3046
3161
|
const lines = s.split(`
|
|
3047
3162
|
`).map((l) => l.trim()).filter(Boolean);
|
|
@@ -3189,8 +3304,14 @@ async function burnRun(ctx, deps) {
|
|
|
3189
3304
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
3190
3305
|
}
|
|
3191
3306
|
function burnerTemplatePath() {
|
|
3307
|
+
return burnerAssetPath("implement-ticket.md");
|
|
3308
|
+
}
|
|
3309
|
+
function resolverTemplatePath() {
|
|
3310
|
+
return burnerAssetPath("resolve-conflict.md");
|
|
3311
|
+
}
|
|
3312
|
+
function burnerAssetPath(file) {
|
|
3192
3313
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3193
|
-
return join9(resolveSkillsRoot(here), "burner",
|
|
3314
|
+
return join9(resolveSkillsRoot(here), "burner", file);
|
|
3194
3315
|
}
|
|
3195
3316
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3196
3317
|
const worktree = worktreeDir(projectId, slug);
|
|
@@ -3254,13 +3375,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3254
3375
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
3255
3376
|
if (workspaceMode === "isolated")
|
|
3256
3377
|
await ensureIsolatedPushTarget();
|
|
3257
|
-
const
|
|
3258
|
-
const
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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,
|
|
3262
3386
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
|
|
3263
|
-
WORKSPACE_NOTES:
|
|
3387
|
+
WORKSPACE_NOTES: workspaceNotes
|
|
3264
3388
|
});
|
|
3265
3389
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3266
3390
|
const pm = detectPackageManager(toolchain);
|
|
@@ -3293,9 +3417,10 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3293
3417
|
activeTicketAborts.set(ticket.id, ticketAbort);
|
|
3294
3418
|
const signal = AbortSignal.any([ctx.signal, ticketAbort.signal]);
|
|
3295
3419
|
const maxAttempts = Math.max(1, config2.burnAttempts);
|
|
3420
|
+
const conflictResume = ticket.conflictFiles !== undefined && !!ticket.attemptBranch;
|
|
3296
3421
|
let baseBranch = feature.branch;
|
|
3297
3422
|
let retryNotes;
|
|
3298
|
-
if (ticket.attemptBranch) {
|
|
3423
|
+
if (ticket.attemptBranch && !conflictResume) {
|
|
3299
3424
|
const preserved = await branchCommitsAhead(project.repoPath, feature.branch, ticket.attemptBranch);
|
|
3300
3425
|
if (preserved.length > 0) {
|
|
3301
3426
|
baseBranch = ticket.attemptBranch;
|
|
@@ -3314,7 +3439,140 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3314
3439
|
const preserveChain = (branch) => {
|
|
3315
3440
|
ctx.updateTicket(ticket.id, { attemptBranch: branch });
|
|
3316
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
|
+
};
|
|
3317
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
|
+
}
|
|
3318
3576
|
let result;
|
|
3319
3577
|
let tempBranch = "";
|
|
3320
3578
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3370,12 +3628,16 @@ ${retryNotes}` : basePrompt,
|
|
|
3370
3628
|
};
|
|
3371
3629
|
}
|
|
3372
3630
|
if (isMergeConflictError(err)) {
|
|
3631
|
+
if (salvaged.length > 0) {
|
|
3632
|
+
preserveChain(tempBranch);
|
|
3633
|
+
ctx.updateTicket(ticket.id, { conflictFiles: [] });
|
|
3634
|
+
}
|
|
3373
3635
|
return {
|
|
3374
3636
|
status: "failed",
|
|
3375
3637
|
error: `merge conflict landing ticket ${ticket.seq} on ${feature.branch}: ${msg}`,
|
|
3376
3638
|
event: {
|
|
3377
3639
|
type: "merge.conflict.needs-human",
|
|
3378
|
-
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"}`
|
|
3379
3641
|
}
|
|
3380
3642
|
};
|
|
3381
3643
|
}
|
|
@@ -3433,28 +3695,7 @@ ${blocked.trim()}` };
|
|
|
3433
3695
|
preserveChain(tempBranch);
|
|
3434
3696
|
return outcome;
|
|
3435
3697
|
}
|
|
3436
|
-
|
|
3437
|
-
if (!merge.ok) {
|
|
3438
|
-
const detail = merge.error ?? "merge failed";
|
|
3439
|
-
if (merge.conflict) {
|
|
3440
|
-
return {
|
|
3441
|
-
status: "failed",
|
|
3442
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
3443
|
-
event: {
|
|
3444
|
-
type: "merge.conflict.needs-human",
|
|
3445
|
-
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
3446
|
-
}
|
|
3447
|
-
};
|
|
3448
|
-
}
|
|
3449
|
-
preserveChain(tempBranch);
|
|
3450
|
-
return {
|
|
3451
|
-
status: "failed",
|
|
3452
|
-
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
3453
|
-
};
|
|
3454
|
-
}
|
|
3455
|
-
if (ticket.attemptBranch)
|
|
3456
|
-
ctx.updateTicket(ticket.id, { attemptBranch: null });
|
|
3457
|
-
return outcome;
|
|
3698
|
+
return await landChain(tempBranch, outcome.commits);
|
|
3458
3699
|
} finally {
|
|
3459
3700
|
activeTicketAborts.delete(ticket.id);
|
|
3460
3701
|
throttle.flush();
|
|
@@ -3487,7 +3728,7 @@ function readTokenFromEnvFile(path) {
|
|
|
3487
3728
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3488
3729
|
return token && token.length > 0 ? token : undefined;
|
|
3489
3730
|
}
|
|
3490
|
-
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;
|
|
3491
3732
|
var init_ticket_burner = __esm(() => {
|
|
3492
3733
|
init_src();
|
|
3493
3734
|
init_config_load();
|
|
@@ -3495,13 +3736,6 @@ var init_ticket_burner = __esm(() => {
|
|
|
3495
3736
|
init_skills_root();
|
|
3496
3737
|
init_agent_stream();
|
|
3497
3738
|
init_git();
|
|
3498
|
-
PLACEHOLDERS = [
|
|
3499
|
-
"TICKET_JSON",
|
|
3500
|
-
"FEATURE_BRIEF",
|
|
3501
|
-
"DOCS_DIGEST",
|
|
3502
|
-
"COMMIT_CONVENTION",
|
|
3503
|
-
"WORKSPACE_NOTES"
|
|
3504
|
-
];
|
|
3505
3739
|
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3506
3740
|
PM_CACHE_SANDBOX_PATHS = {
|
|
3507
3741
|
bun: "~/.bun/install/cache",
|
|
@@ -3592,7 +3826,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
|
|
|
3592
3826
|
}
|
|
3593
3827
|
function renderResearchPrompt(template, values) {
|
|
3594
3828
|
let out = template;
|
|
3595
|
-
for (const key of
|
|
3829
|
+
for (const key of PLACEHOLDERS) {
|
|
3596
3830
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
3597
3831
|
}
|
|
3598
3832
|
return out;
|
|
@@ -3694,7 +3928,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
3694
3928
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3695
3929
|
return token && token.length > 0 ? token : undefined;
|
|
3696
3930
|
}
|
|
3697
|
-
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;
|
|
3698
3932
|
var init_research = __esm(() => {
|
|
3699
3933
|
init_src();
|
|
3700
3934
|
init_config_load();
|
|
@@ -3702,7 +3936,7 @@ var init_research = __esm(() => {
|
|
|
3702
3936
|
init_skills_root();
|
|
3703
3937
|
init_git();
|
|
3704
3938
|
init_ticket_burner();
|
|
3705
|
-
|
|
3939
|
+
PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
3706
3940
|
research = {
|
|
3707
3941
|
id: "research",
|
|
3708
3942
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4042,24 +4276,26 @@ async function retryTicket(ctx, ticketId, opts = {}) {
|
|
|
4042
4276
|
preservedCommits = found.commits.length;
|
|
4043
4277
|
}
|
|
4044
4278
|
}
|
|
4279
|
+
const resolvingConflict = !opts.fresh && ticket.conflictFiles !== undefined && !!resumedFrom;
|
|
4045
4280
|
for (const t of toReset.values()) {
|
|
4046
4281
|
const isTarget = t.id === ticket.id;
|
|
4047
4282
|
updateTicket(ctx, t.id, {
|
|
4048
4283
|
status: "pending",
|
|
4049
4284
|
error: null,
|
|
4050
|
-
...isTarget ? { attemptBranch: resumedFrom } : {}
|
|
4285
|
+
...isTarget ? { attemptBranch: resumedFrom } : {},
|
|
4286
|
+
...isTarget && !resolvingConflict ? { conflictFiles: null } : {}
|
|
4051
4287
|
});
|
|
4052
4288
|
}
|
|
4053
4289
|
const seqs = [...toReset.keys()].sort((a, b) => a - b);
|
|
4054
|
-
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}` : "";
|
|
4055
4291
|
emit(ctx, feature.id, {
|
|
4056
4292
|
type: "ticket.retry",
|
|
4057
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}`,
|
|
4058
4294
|
ticketId: ticket.id,
|
|
4059
|
-
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits }
|
|
4295
|
+
data: { retried: seqs, fresh: !!opts.fresh, resumedFrom, preservedCommits, resolvingConflict }
|
|
4060
4296
|
});
|
|
4061
4297
|
const { runId } = await burn(ctx, feature.id, { resetFailed: false });
|
|
4062
|
-
return { runId, retried: seqs, resumedFrom, preservedCommits };
|
|
4298
|
+
return { runId, retried: seqs, resumedFrom, preservedCommits, resolvingConflict };
|
|
4063
4299
|
}
|
|
4064
4300
|
function escalateToMap(ctx, featureId, input) {
|
|
4065
4301
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -4636,6 +4872,57 @@ var init_hooks = __esm(() => {
|
|
|
4636
4872
|
hooks_default = hooks;
|
|
4637
4873
|
});
|
|
4638
4874
|
|
|
4875
|
+
// src/routes/stream.ts
|
|
4876
|
+
import { Hono as Hono3 } from "hono";
|
|
4877
|
+
import { streamSSE } from "hono/streaming";
|
|
4878
|
+
function dedupeKey(s) {
|
|
4879
|
+
return s.kind === "transcript" ? `transcript:${s.ticketId}` : "event";
|
|
4880
|
+
}
|
|
4881
|
+
var stream, FLUSH_MS = 120, HEARTBEAT_MS = 25000, stream_default;
|
|
4882
|
+
var init_stream = __esm(() => {
|
|
4883
|
+
init_bus();
|
|
4884
|
+
stream = new Hono3;
|
|
4885
|
+
stream.get("/", (c) => streamSSE(c, async (sse) => {
|
|
4886
|
+
const pending = new Map;
|
|
4887
|
+
let done = false;
|
|
4888
|
+
const stop = () => {
|
|
4889
|
+
done = true;
|
|
4890
|
+
};
|
|
4891
|
+
const unsubscribe = subscribeLive((signal) => {
|
|
4892
|
+
pending.set(dedupeKey(signal), signal);
|
|
4893
|
+
});
|
|
4894
|
+
sse.onAbort(stop);
|
|
4895
|
+
c.req.raw.signal.addEventListener("abort", stop);
|
|
4896
|
+
try {
|
|
4897
|
+
await sse.writeSSE({ event: "ready", data: "{}" });
|
|
4898
|
+
let idleMs = 0;
|
|
4899
|
+
while (!done && !sse.aborted && !sse.closed) {
|
|
4900
|
+
await sse.sleep(FLUSH_MS);
|
|
4901
|
+
if (done || sse.aborted || sse.closed)
|
|
4902
|
+
break;
|
|
4903
|
+
if (pending.size === 0) {
|
|
4904
|
+
idleMs += FLUSH_MS;
|
|
4905
|
+
if (idleMs >= HEARTBEAT_MS) {
|
|
4906
|
+
idleMs = 0;
|
|
4907
|
+
await sse.writeSSE({ event: "ping", data: "{}" });
|
|
4908
|
+
}
|
|
4909
|
+
continue;
|
|
4910
|
+
}
|
|
4911
|
+
idleMs = 0;
|
|
4912
|
+
const batch = [...pending.values()];
|
|
4913
|
+
pending.clear();
|
|
4914
|
+
for (const signal of batch) {
|
|
4915
|
+
await sse.writeSSE({ event: "live", data: JSON.stringify(signal) });
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
4918
|
+
} finally {
|
|
4919
|
+
unsubscribe();
|
|
4920
|
+
c.req.raw.signal.removeEventListener("abort", stop);
|
|
4921
|
+
}
|
|
4922
|
+
}));
|
|
4923
|
+
stream_default = stream;
|
|
4924
|
+
});
|
|
4925
|
+
|
|
4639
4926
|
// src/routes/web.ts
|
|
4640
4927
|
import { existsSync as existsSync12, statSync } from "fs";
|
|
4641
4928
|
import { readFile } from "fs/promises";
|
|
@@ -5969,6 +6256,14 @@ var init_settings = __esm(() => {
|
|
|
5969
6256
|
valueSchema: z9.number().int().min(1).max(5),
|
|
5970
6257
|
parseEnv: (raw) => Number(raw)
|
|
5971
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
|
+
},
|
|
5972
6267
|
{
|
|
5973
6268
|
key: "setupCommand",
|
|
5974
6269
|
configKey: "setupCommand",
|
|
@@ -6610,9 +6905,9 @@ __export(exports_src, {
|
|
|
6610
6905
|
buildApp: () => buildApp
|
|
6611
6906
|
});
|
|
6612
6907
|
import { trpcServer } from "@hono/trpc-server";
|
|
6613
|
-
import { Hono as
|
|
6908
|
+
import { Hono as Hono4 } from "hono";
|
|
6614
6909
|
function buildApp(ctx) {
|
|
6615
|
-
const app = new
|
|
6910
|
+
const app = new Hono4;
|
|
6616
6911
|
setRuntimeCtx(ctx);
|
|
6617
6912
|
app.get("/health", (c) => c.json({ ok: true }));
|
|
6618
6913
|
app.use("/api/trpc/*", async (c, next) => {
|
|
@@ -6626,6 +6921,7 @@ function buildApp(ctx) {
|
|
|
6626
6921
|
createContext: () => ctx
|
|
6627
6922
|
}));
|
|
6628
6923
|
app.route("/api/hooks", hooks_default);
|
|
6924
|
+
app.route("/api/stream", stream_default);
|
|
6629
6925
|
app.route("/mcp", server_default);
|
|
6630
6926
|
mountWebAppIfBuilt(app);
|
|
6631
6927
|
return app;
|
|
@@ -6678,6 +6974,7 @@ var init_src2 = __esm(() => {
|
|
|
6678
6974
|
init_registry();
|
|
6679
6975
|
init_ws();
|
|
6680
6976
|
init_hooks();
|
|
6977
|
+
init_stream();
|
|
6681
6978
|
init_web();
|
|
6682
6979
|
init_router();
|
|
6683
6980
|
init_reconcile_runs();
|