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