runcastle 1.0.8 → 1.0.9
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 +185 -7
- package/index.js +185 -7
- package/package.json +1 -1
- package/skills/burner/implement-ticket.md +9 -1
- package/web/assets/{index-Y3ehCYIg.js → index-C1LxVTPy.js} +2 -2
- package/web/assets/index-JQYIwCj3.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-EKuI75rm.css +0 -1
package/bin/runcastle.js
CHANGED
|
@@ -27,7 +27,8 @@ __export(exports_paths, {
|
|
|
27
27
|
envPath: () => envPath,
|
|
28
28
|
dbPath: () => dbPath,
|
|
29
29
|
dataDir: () => dataDir,
|
|
30
|
-
configPath: () => configPath
|
|
30
|
+
configPath: () => configPath,
|
|
31
|
+
burnCacheDir: () => burnCacheDir
|
|
31
32
|
});
|
|
32
33
|
import { homedir } from "os";
|
|
33
34
|
import { join } from "path";
|
|
@@ -52,6 +53,9 @@ function sessionDir(id) {
|
|
|
52
53
|
function worktreeDir(projectId, slug) {
|
|
53
54
|
return join(dataDir(), "worktrees", projectId, slug);
|
|
54
55
|
}
|
|
56
|
+
function burnCacheDir(pm) {
|
|
57
|
+
return join(dataDir(), "cache", pm);
|
|
58
|
+
}
|
|
55
59
|
function sandboxBuildDir() {
|
|
56
60
|
return join(dataDir(), "sandbox-build");
|
|
57
61
|
}
|
|
@@ -100,7 +104,9 @@ var init_config = __esm(() => {
|
|
|
100
104
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
101
105
|
mainBranch: z.string().default("main"),
|
|
102
106
|
sandboxImage: z.string().optional(),
|
|
103
|
-
burnConcurrency: z.number().int().min(1).max(8).default(3)
|
|
107
|
+
burnConcurrency: z.number().int().min(1).max(8).default(3),
|
|
108
|
+
burnMaxIterations: z.number().int().min(1).max(10).default(3),
|
|
109
|
+
setupCommand: z.string().optional()
|
|
104
110
|
}));
|
|
105
111
|
});
|
|
106
112
|
|
|
@@ -136,6 +142,11 @@ function loadConfig(env = process.env) {
|
|
|
136
142
|
if (env.RUNCASTLE_BURN_CONCURRENCY) {
|
|
137
143
|
overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
|
|
138
144
|
}
|
|
145
|
+
if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
|
|
146
|
+
overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
|
|
147
|
+
}
|
|
148
|
+
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
149
|
+
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
139
150
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
140
151
|
return RuncastleConfig.parse({ ...base, ...overrides });
|
|
141
152
|
}
|
|
@@ -2492,6 +2503,46 @@ var init_skills_root = __esm(() => {
|
|
|
2492
2503
|
SKILLS_MARKER = join7("packs", "runcastle");
|
|
2493
2504
|
});
|
|
2494
2505
|
|
|
2506
|
+
// src/services/agent-stream.ts
|
|
2507
|
+
function beginTranscript(ticketId) {
|
|
2508
|
+
transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
|
|
2509
|
+
}
|
|
2510
|
+
function appendTranscript(ticketId, chunk) {
|
|
2511
|
+
const t = transcripts.get(ticketId);
|
|
2512
|
+
if (!t)
|
|
2513
|
+
return;
|
|
2514
|
+
const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
|
|
2515
|
+
t.nextIndex += 1;
|
|
2516
|
+
t.chunks.push(full);
|
|
2517
|
+
t.bytes += full.text.length;
|
|
2518
|
+
while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
|
|
2519
|
+
const dropped = t.chunks.shift();
|
|
2520
|
+
if (!dropped)
|
|
2521
|
+
break;
|
|
2522
|
+
t.bytes -= dropped.text.length;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
function endTranscript(ticketId) {
|
|
2526
|
+
const t = transcripts.get(ticketId);
|
|
2527
|
+
if (t)
|
|
2528
|
+
t.live = false;
|
|
2529
|
+
}
|
|
2530
|
+
function readTranscript(ticketId, after = -1) {
|
|
2531
|
+
const t = transcripts.get(ticketId);
|
|
2532
|
+
if (!t)
|
|
2533
|
+
return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
|
|
2534
|
+
return {
|
|
2535
|
+
live: t.live,
|
|
2536
|
+
chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
|
|
2537
|
+
firstIndex: t.chunks[0]?.i ?? t.nextIndex,
|
|
2538
|
+
nextIndex: t.nextIndex
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
|
|
2542
|
+
var init_agent_stream = __esm(() => {
|
|
2543
|
+
transcripts = new Map;
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2495
2546
|
// src/workflows/ticket-burner.ts
|
|
2496
2547
|
import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
|
|
2497
2548
|
import { dirname as dirname3, join as join8 } from "path";
|
|
@@ -2604,6 +2655,64 @@ function parseEnvFile(content) {
|
|
|
2604
2655
|
}
|
|
2605
2656
|
return out;
|
|
2606
2657
|
}
|
|
2658
|
+
function detectPackageManager(tc) {
|
|
2659
|
+
const pinned = tc.packageManagerField?.split("@")[0];
|
|
2660
|
+
if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
|
|
2661
|
+
return pinned;
|
|
2662
|
+
}
|
|
2663
|
+
if (tc.lockfiles.bun)
|
|
2664
|
+
return "bun";
|
|
2665
|
+
if (tc.lockfiles.pnpm)
|
|
2666
|
+
return "pnpm";
|
|
2667
|
+
if (tc.lockfiles.yarn)
|
|
2668
|
+
return "yarn";
|
|
2669
|
+
if (tc.lockfiles.npm)
|
|
2670
|
+
return "npm";
|
|
2671
|
+
return tc.hasPackageJson ? "npm" : undefined;
|
|
2672
|
+
}
|
|
2673
|
+
function resolveSetupCommand(tc, override) {
|
|
2674
|
+
const trimmed = override?.trim();
|
|
2675
|
+
if (trimmed)
|
|
2676
|
+
return trimmed;
|
|
2677
|
+
const pm = detectPackageManager(tc);
|
|
2678
|
+
if (!pm)
|
|
2679
|
+
return;
|
|
2680
|
+
switch (pm) {
|
|
2681
|
+
case "bun":
|
|
2682
|
+
return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
|
|
2683
|
+
case "pnpm":
|
|
2684
|
+
return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
|
|
2685
|
+
case "yarn":
|
|
2686
|
+
return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
|
|
2687
|
+
case "npm":
|
|
2688
|
+
return tc.lockfiles.npm ? "npm ci" : "npm install";
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
function cacheMountFor(pm, hostPath) {
|
|
2692
|
+
return { hostPath, sandboxPath: PM_CACHE_SANDBOX_PATHS[pm] };
|
|
2693
|
+
}
|
|
2694
|
+
function readRepoToolchain(repoPath) {
|
|
2695
|
+
const pkgPath = join8(repoPath, "package.json");
|
|
2696
|
+
const hasPackageJson = existsSync10(pkgPath);
|
|
2697
|
+
let packageManagerField;
|
|
2698
|
+
if (hasPackageJson) {
|
|
2699
|
+
try {
|
|
2700
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
2701
|
+
if (typeof pkg.packageManager === "string")
|
|
2702
|
+
packageManagerField = pkg.packageManager;
|
|
2703
|
+
} catch {}
|
|
2704
|
+
}
|
|
2705
|
+
return {
|
|
2706
|
+
hasPackageJson,
|
|
2707
|
+
...packageManagerField !== undefined ? { packageManagerField } : {},
|
|
2708
|
+
lockfiles: {
|
|
2709
|
+
bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
|
|
2710
|
+
pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
|
|
2711
|
+
yarn: existsSync10(join8(repoPath, "yarn.lock")),
|
|
2712
|
+
npm: existsSync10(join8(repoPath, "package-lock.json"))
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2607
2716
|
function createStreamThrottle(emit2, opts = {}) {
|
|
2608
2717
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
2609
2718
|
const maxChars = opts.maxChars ?? 500;
|
|
@@ -2855,8 +2964,11 @@ function buildBurnAgent(config2, token, model) {
|
|
|
2855
2964
|
}
|
|
2856
2965
|
return agent;
|
|
2857
2966
|
}
|
|
2858
|
-
function selectSandbox(config2) {
|
|
2859
|
-
const imageOpts = {
|
|
2967
|
+
function selectSandbox(config2, mounts = []) {
|
|
2968
|
+
const imageOpts = {
|
|
2969
|
+
imageName: resolveSandboxImage(config2),
|
|
2970
|
+
...mounts.length > 0 ? { mounts } : {}
|
|
2971
|
+
};
|
|
2860
2972
|
switch (config2.sandbox) {
|
|
2861
2973
|
case "docker":
|
|
2862
2974
|
return docker(imageOpts);
|
|
@@ -2876,24 +2988,62 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
|
2876
2988
|
DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
|
|
2877
2989
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
|
|
2878
2990
|
});
|
|
2991
|
+
const toolchain = readRepoToolchain(project.repoPath);
|
|
2992
|
+
const pm = detectPackageManager(toolchain);
|
|
2993
|
+
const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
|
|
2994
|
+
const mounts = [];
|
|
2995
|
+
if (config2.sandbox !== "noSandbox" && pm) {
|
|
2996
|
+
const hostCache = burnCacheDir(pm);
|
|
2997
|
+
mkdirSync4(hostCache, { recursive: true });
|
|
2998
|
+
mounts.push(cacheMountFor(pm, hostCache));
|
|
2999
|
+
}
|
|
3000
|
+
if (setupCommand) {
|
|
3001
|
+
ctx.emitEvent({
|
|
3002
|
+
type: "burn.setup",
|
|
3003
|
+
message: `installing deps before agent start: ${setupCommand}`,
|
|
3004
|
+
ticketId: ticket.id
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
2879
3007
|
mkdirSync4(logsDir(), { recursive: true });
|
|
2880
3008
|
const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
2881
3009
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
3010
|
+
beginTranscript(ticket.id);
|
|
3011
|
+
const onStreamEvent = (event) => {
|
|
3012
|
+
throttle.onEvent(event);
|
|
3013
|
+
if (event.type === "text") {
|
|
3014
|
+
appendTranscript(ticket.id, { kind: "text", text: event.message });
|
|
3015
|
+
} else if (event.type === "toolCall") {
|
|
3016
|
+
appendTranscript(ticket.id, {
|
|
3017
|
+
kind: "tool",
|
|
3018
|
+
text: event.formattedArgs ?? "",
|
|
3019
|
+
name: event.name
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
};
|
|
2882
3023
|
const runOptions = {
|
|
2883
3024
|
agent: buildBurnAgent(config2, token, model),
|
|
2884
|
-
sandbox: selectSandbox(config2),
|
|
3025
|
+
sandbox: selectSandbox(config2, mounts),
|
|
2885
3026
|
cwd: project.repoPath,
|
|
2886
3027
|
prompt,
|
|
2887
3028
|
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2888
3029
|
signal: ctx.signal,
|
|
2889
3030
|
name: `ticket-${ticket.seq}`,
|
|
2890
|
-
|
|
3031
|
+
maxIterations: config2.burnMaxIterations,
|
|
3032
|
+
...setupCommand ? {
|
|
3033
|
+
hooks: {
|
|
3034
|
+
sandbox: {
|
|
3035
|
+
onSandboxReady: [{ command: setupCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
} : {},
|
|
3039
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
2891
3040
|
};
|
|
2892
3041
|
let result;
|
|
2893
3042
|
try {
|
|
2894
3043
|
result = await run(runOptions);
|
|
2895
3044
|
} catch (err) {
|
|
2896
3045
|
throttle.flush();
|
|
3046
|
+
endTranscript(ticket.id);
|
|
2897
3047
|
if (ctx.signal.aborted)
|
|
2898
3048
|
throw err;
|
|
2899
3049
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -2910,6 +3060,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
|
2910
3060
|
return { status: "failed", error: msg };
|
|
2911
3061
|
}
|
|
2912
3062
|
throttle.flush();
|
|
3063
|
+
endTranscript(ticket.id);
|
|
2913
3064
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2914
3065
|
const outcome = interpretRunResult(result, blocked);
|
|
2915
3066
|
if (outcome.status !== "done")
|
|
@@ -2958,14 +3109,23 @@ function readTokenFromEnvFile(path) {
|
|
|
2958
3109
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2959
3110
|
return token && token.length > 0 ? token : undefined;
|
|
2960
3111
|
}
|
|
2961
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
|
|
3112
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
|
|
2962
3113
|
var init_ticket_burner = __esm(() => {
|
|
2963
3114
|
init_src();
|
|
2964
3115
|
init_config_load();
|
|
2965
3116
|
init_paths();
|
|
2966
3117
|
init_skills_root();
|
|
3118
|
+
init_agent_stream();
|
|
2967
3119
|
init_git();
|
|
2968
3120
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
3121
|
+
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3122
|
+
PM_CACHE_SANDBOX_PATHS = {
|
|
3123
|
+
bun: "~/.bun/install/cache",
|
|
3124
|
+
pnpm: "~/.local/share/pnpm/store",
|
|
3125
|
+
yarn: "~/.cache/yarn",
|
|
3126
|
+
npm: "~/.npm"
|
|
3127
|
+
};
|
|
3128
|
+
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
2969
3129
|
ticketBurner = {
|
|
2970
3130
|
id: "ticket-burner",
|
|
2971
3131
|
run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
|
|
@@ -4844,11 +5004,13 @@ var init_project = __esm(() => {
|
|
|
4844
5004
|
import * as z8 from "zod";
|
|
4845
5005
|
var runRouter;
|
|
4846
5006
|
var init_run = __esm(() => {
|
|
5007
|
+
init_agent_stream();
|
|
4847
5008
|
init_repo();
|
|
4848
5009
|
init_runner();
|
|
4849
5010
|
init_context();
|
|
4850
5011
|
runRouter = router({
|
|
4851
5012
|
get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
|
|
5013
|
+
agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
|
|
4852
5014
|
cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
|
|
4853
5015
|
cancelRun(input.runId);
|
|
4854
5016
|
return { ok: true };
|
|
@@ -5084,6 +5246,22 @@ var init_settings = __esm(() => {
|
|
|
5084
5246
|
valueSchema: z9.number().int().min(1).max(8),
|
|
5085
5247
|
parseEnv: (raw) => Number(raw)
|
|
5086
5248
|
},
|
|
5249
|
+
{
|
|
5250
|
+
key: "burnMaxIterations",
|
|
5251
|
+
configKey: "burnMaxIterations",
|
|
5252
|
+
envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
|
|
5253
|
+
restartRequired: false,
|
|
5254
|
+
valueSchema: z9.number().int().min(1).max(10),
|
|
5255
|
+
parseEnv: (raw) => Number(raw)
|
|
5256
|
+
},
|
|
5257
|
+
{
|
|
5258
|
+
key: "setupCommand",
|
|
5259
|
+
configKey: "setupCommand",
|
|
5260
|
+
envVar: "RUNCASTLE_SETUP_COMMAND",
|
|
5261
|
+
restartRequired: false,
|
|
5262
|
+
valueSchema: z9.string().min(1),
|
|
5263
|
+
parseEnv: idEnv
|
|
5264
|
+
},
|
|
5087
5265
|
{
|
|
5088
5266
|
key: "mainBranch",
|
|
5089
5267
|
configKey: "mainBranch",
|
package/index.js
CHANGED
|
@@ -26,7 +26,8 @@ __export(exports_paths, {
|
|
|
26
26
|
envPath: () => envPath,
|
|
27
27
|
dbPath: () => dbPath,
|
|
28
28
|
dataDir: () => dataDir,
|
|
29
|
-
configPath: () => configPath
|
|
29
|
+
configPath: () => configPath,
|
|
30
|
+
burnCacheDir: () => burnCacheDir
|
|
30
31
|
});
|
|
31
32
|
import { homedir } from "os";
|
|
32
33
|
import { join } from "path";
|
|
@@ -51,6 +52,9 @@ function sessionDir(id) {
|
|
|
51
52
|
function worktreeDir(projectId, slug) {
|
|
52
53
|
return join(dataDir(), "worktrees", projectId, slug);
|
|
53
54
|
}
|
|
55
|
+
function burnCacheDir(pm) {
|
|
56
|
+
return join(dataDir(), "cache", pm);
|
|
57
|
+
}
|
|
54
58
|
function sandboxBuildDir() {
|
|
55
59
|
return join(dataDir(), "sandbox-build");
|
|
56
60
|
}
|
|
@@ -99,7 +103,9 @@ var init_config = __esm(() => {
|
|
|
99
103
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
100
104
|
mainBranch: z.string().default("main"),
|
|
101
105
|
sandboxImage: z.string().optional(),
|
|
102
|
-
burnConcurrency: z.number().int().min(1).max(8).default(3)
|
|
106
|
+
burnConcurrency: z.number().int().min(1).max(8).default(3),
|
|
107
|
+
burnMaxIterations: z.number().int().min(1).max(10).default(3),
|
|
108
|
+
setupCommand: z.string().optional()
|
|
103
109
|
}));
|
|
104
110
|
});
|
|
105
111
|
|
|
@@ -135,6 +141,11 @@ function loadConfig(env = process.env) {
|
|
|
135
141
|
if (env.RUNCASTLE_BURN_CONCURRENCY) {
|
|
136
142
|
overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
|
|
137
143
|
}
|
|
144
|
+
if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
|
|
145
|
+
overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
|
|
146
|
+
}
|
|
147
|
+
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
148
|
+
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
138
149
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
139
150
|
return RuncastleConfig.parse({ ...base, ...overrides });
|
|
140
151
|
}
|
|
@@ -2491,6 +2502,46 @@ var init_skills_root = __esm(() => {
|
|
|
2491
2502
|
SKILLS_MARKER = join7("packs", "runcastle");
|
|
2492
2503
|
});
|
|
2493
2504
|
|
|
2505
|
+
// src/services/agent-stream.ts
|
|
2506
|
+
function beginTranscript(ticketId) {
|
|
2507
|
+
transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
|
|
2508
|
+
}
|
|
2509
|
+
function appendTranscript(ticketId, chunk) {
|
|
2510
|
+
const t = transcripts.get(ticketId);
|
|
2511
|
+
if (!t)
|
|
2512
|
+
return;
|
|
2513
|
+
const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
|
|
2514
|
+
t.nextIndex += 1;
|
|
2515
|
+
t.chunks.push(full);
|
|
2516
|
+
t.bytes += full.text.length;
|
|
2517
|
+
while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
|
|
2518
|
+
const dropped = t.chunks.shift();
|
|
2519
|
+
if (!dropped)
|
|
2520
|
+
break;
|
|
2521
|
+
t.bytes -= dropped.text.length;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
function endTranscript(ticketId) {
|
|
2525
|
+
const t = transcripts.get(ticketId);
|
|
2526
|
+
if (t)
|
|
2527
|
+
t.live = false;
|
|
2528
|
+
}
|
|
2529
|
+
function readTranscript(ticketId, after = -1) {
|
|
2530
|
+
const t = transcripts.get(ticketId);
|
|
2531
|
+
if (!t)
|
|
2532
|
+
return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
|
|
2533
|
+
return {
|
|
2534
|
+
live: t.live,
|
|
2535
|
+
chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
|
|
2536
|
+
firstIndex: t.chunks[0]?.i ?? t.nextIndex,
|
|
2537
|
+
nextIndex: t.nextIndex
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
|
|
2541
|
+
var init_agent_stream = __esm(() => {
|
|
2542
|
+
transcripts = new Map;
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2494
2545
|
// src/workflows/ticket-burner.ts
|
|
2495
2546
|
import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
|
|
2496
2547
|
import { dirname as dirname3, join as join8 } from "path";
|
|
@@ -2603,6 +2654,64 @@ function parseEnvFile(content) {
|
|
|
2603
2654
|
}
|
|
2604
2655
|
return out;
|
|
2605
2656
|
}
|
|
2657
|
+
function detectPackageManager(tc) {
|
|
2658
|
+
const pinned = tc.packageManagerField?.split("@")[0];
|
|
2659
|
+
if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
|
|
2660
|
+
return pinned;
|
|
2661
|
+
}
|
|
2662
|
+
if (tc.lockfiles.bun)
|
|
2663
|
+
return "bun";
|
|
2664
|
+
if (tc.lockfiles.pnpm)
|
|
2665
|
+
return "pnpm";
|
|
2666
|
+
if (tc.lockfiles.yarn)
|
|
2667
|
+
return "yarn";
|
|
2668
|
+
if (tc.lockfiles.npm)
|
|
2669
|
+
return "npm";
|
|
2670
|
+
return tc.hasPackageJson ? "npm" : undefined;
|
|
2671
|
+
}
|
|
2672
|
+
function resolveSetupCommand(tc, override) {
|
|
2673
|
+
const trimmed = override?.trim();
|
|
2674
|
+
if (trimmed)
|
|
2675
|
+
return trimmed;
|
|
2676
|
+
const pm = detectPackageManager(tc);
|
|
2677
|
+
if (!pm)
|
|
2678
|
+
return;
|
|
2679
|
+
switch (pm) {
|
|
2680
|
+
case "bun":
|
|
2681
|
+
return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
|
|
2682
|
+
case "pnpm":
|
|
2683
|
+
return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
|
|
2684
|
+
case "yarn":
|
|
2685
|
+
return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
|
|
2686
|
+
case "npm":
|
|
2687
|
+
return tc.lockfiles.npm ? "npm ci" : "npm install";
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function cacheMountFor(pm, hostPath) {
|
|
2691
|
+
return { hostPath, sandboxPath: PM_CACHE_SANDBOX_PATHS[pm] };
|
|
2692
|
+
}
|
|
2693
|
+
function readRepoToolchain(repoPath) {
|
|
2694
|
+
const pkgPath = join8(repoPath, "package.json");
|
|
2695
|
+
const hasPackageJson = existsSync10(pkgPath);
|
|
2696
|
+
let packageManagerField;
|
|
2697
|
+
if (hasPackageJson) {
|
|
2698
|
+
try {
|
|
2699
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
2700
|
+
if (typeof pkg.packageManager === "string")
|
|
2701
|
+
packageManagerField = pkg.packageManager;
|
|
2702
|
+
} catch {}
|
|
2703
|
+
}
|
|
2704
|
+
return {
|
|
2705
|
+
hasPackageJson,
|
|
2706
|
+
...packageManagerField !== undefined ? { packageManagerField } : {},
|
|
2707
|
+
lockfiles: {
|
|
2708
|
+
bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
|
|
2709
|
+
pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
|
|
2710
|
+
yarn: existsSync10(join8(repoPath, "yarn.lock")),
|
|
2711
|
+
npm: existsSync10(join8(repoPath, "package-lock.json"))
|
|
2712
|
+
}
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2606
2715
|
function createStreamThrottle(emit2, opts = {}) {
|
|
2607
2716
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
2608
2717
|
const maxChars = opts.maxChars ?? 500;
|
|
@@ -2854,8 +2963,11 @@ function buildBurnAgent(config2, token, model) {
|
|
|
2854
2963
|
}
|
|
2855
2964
|
return agent;
|
|
2856
2965
|
}
|
|
2857
|
-
function selectSandbox(config2) {
|
|
2858
|
-
const imageOpts = {
|
|
2966
|
+
function selectSandbox(config2, mounts = []) {
|
|
2967
|
+
const imageOpts = {
|
|
2968
|
+
imageName: resolveSandboxImage(config2),
|
|
2969
|
+
...mounts.length > 0 ? { mounts } : {}
|
|
2970
|
+
};
|
|
2859
2971
|
switch (config2.sandbox) {
|
|
2860
2972
|
case "docker":
|
|
2861
2973
|
return docker(imageOpts);
|
|
@@ -2875,24 +2987,62 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
|
2875
2987
|
DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
|
|
2876
2988
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
|
|
2877
2989
|
});
|
|
2990
|
+
const toolchain = readRepoToolchain(project.repoPath);
|
|
2991
|
+
const pm = detectPackageManager(toolchain);
|
|
2992
|
+
const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
|
|
2993
|
+
const mounts = [];
|
|
2994
|
+
if (config2.sandbox !== "noSandbox" && pm) {
|
|
2995
|
+
const hostCache = burnCacheDir(pm);
|
|
2996
|
+
mkdirSync4(hostCache, { recursive: true });
|
|
2997
|
+
mounts.push(cacheMountFor(pm, hostCache));
|
|
2998
|
+
}
|
|
2999
|
+
if (setupCommand) {
|
|
3000
|
+
ctx.emitEvent({
|
|
3001
|
+
type: "burn.setup",
|
|
3002
|
+
message: `installing deps before agent start: ${setupCommand}`,
|
|
3003
|
+
ticketId: ticket.id
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
2878
3006
|
mkdirSync4(logsDir(), { recursive: true });
|
|
2879
3007
|
const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
2880
3008
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
3009
|
+
beginTranscript(ticket.id);
|
|
3010
|
+
const onStreamEvent = (event) => {
|
|
3011
|
+
throttle.onEvent(event);
|
|
3012
|
+
if (event.type === "text") {
|
|
3013
|
+
appendTranscript(ticket.id, { kind: "text", text: event.message });
|
|
3014
|
+
} else if (event.type === "toolCall") {
|
|
3015
|
+
appendTranscript(ticket.id, {
|
|
3016
|
+
kind: "tool",
|
|
3017
|
+
text: event.formattedArgs ?? "",
|
|
3018
|
+
name: event.name
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
};
|
|
2881
3022
|
const runOptions = {
|
|
2882
3023
|
agent: buildBurnAgent(config2, token, model),
|
|
2883
|
-
sandbox: selectSandbox(config2),
|
|
3024
|
+
sandbox: selectSandbox(config2, mounts),
|
|
2884
3025
|
cwd: project.repoPath,
|
|
2885
3026
|
prompt,
|
|
2886
3027
|
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2887
3028
|
signal: ctx.signal,
|
|
2888
3029
|
name: `ticket-${ticket.seq}`,
|
|
2889
|
-
|
|
3030
|
+
maxIterations: config2.burnMaxIterations,
|
|
3031
|
+
...setupCommand ? {
|
|
3032
|
+
hooks: {
|
|
3033
|
+
sandbox: {
|
|
3034
|
+
onSandboxReady: [{ command: setupCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
} : {},
|
|
3038
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
|
|
2890
3039
|
};
|
|
2891
3040
|
let result;
|
|
2892
3041
|
try {
|
|
2893
3042
|
result = await run(runOptions);
|
|
2894
3043
|
} catch (err) {
|
|
2895
3044
|
throttle.flush();
|
|
3045
|
+
endTranscript(ticket.id);
|
|
2896
3046
|
if (ctx.signal.aborted)
|
|
2897
3047
|
throw err;
|
|
2898
3048
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -2909,6 +3059,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
|
2909
3059
|
return { status: "failed", error: msg };
|
|
2910
3060
|
}
|
|
2911
3061
|
throttle.flush();
|
|
3062
|
+
endTranscript(ticket.id);
|
|
2912
3063
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2913
3064
|
const outcome = interpretRunResult(result, blocked);
|
|
2914
3065
|
if (outcome.status !== "done")
|
|
@@ -2957,14 +3108,23 @@ function readTokenFromEnvFile(path) {
|
|
|
2957
3108
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2958
3109
|
return token && token.length > 0 ? token : undefined;
|
|
2959
3110
|
}
|
|
2960
|
-
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
|
|
3111
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
|
|
2961
3112
|
var init_ticket_burner = __esm(() => {
|
|
2962
3113
|
init_src();
|
|
2963
3114
|
init_config_load();
|
|
2964
3115
|
init_paths();
|
|
2965
3116
|
init_skills_root();
|
|
3117
|
+
init_agent_stream();
|
|
2966
3118
|
init_git();
|
|
2967
3119
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
3120
|
+
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3121
|
+
PM_CACHE_SANDBOX_PATHS = {
|
|
3122
|
+
bun: "~/.bun/install/cache",
|
|
3123
|
+
pnpm: "~/.local/share/pnpm/store",
|
|
3124
|
+
yarn: "~/.cache/yarn",
|
|
3125
|
+
npm: "~/.npm"
|
|
3126
|
+
};
|
|
3127
|
+
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
2968
3128
|
ticketBurner = {
|
|
2969
3129
|
id: "ticket-burner",
|
|
2970
3130
|
run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
|
|
@@ -4843,11 +5003,13 @@ var init_project = __esm(() => {
|
|
|
4843
5003
|
import * as z8 from "zod";
|
|
4844
5004
|
var runRouter;
|
|
4845
5005
|
var init_run = __esm(() => {
|
|
5006
|
+
init_agent_stream();
|
|
4846
5007
|
init_repo();
|
|
4847
5008
|
init_runner();
|
|
4848
5009
|
init_context();
|
|
4849
5010
|
runRouter = router({
|
|
4850
5011
|
get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
|
|
5012
|
+
agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
|
|
4851
5013
|
cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
|
|
4852
5014
|
cancelRun(input.runId);
|
|
4853
5015
|
return { ok: true };
|
|
@@ -5083,6 +5245,22 @@ var init_settings = __esm(() => {
|
|
|
5083
5245
|
valueSchema: z9.number().int().min(1).max(8),
|
|
5084
5246
|
parseEnv: (raw) => Number(raw)
|
|
5085
5247
|
},
|
|
5248
|
+
{
|
|
5249
|
+
key: "burnMaxIterations",
|
|
5250
|
+
configKey: "burnMaxIterations",
|
|
5251
|
+
envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
|
|
5252
|
+
restartRequired: false,
|
|
5253
|
+
valueSchema: z9.number().int().min(1).max(10),
|
|
5254
|
+
parseEnv: (raw) => Number(raw)
|
|
5255
|
+
},
|
|
5256
|
+
{
|
|
5257
|
+
key: "setupCommand",
|
|
5258
|
+
configKey: "setupCommand",
|
|
5259
|
+
envVar: "RUNCASTLE_SETUP_COMMAND",
|
|
5260
|
+
restartRequired: false,
|
|
5261
|
+
valueSchema: z9.string().min(1),
|
|
5262
|
+
parseEnv: idEnv
|
|
5263
|
+
},
|
|
5086
5264
|
{
|
|
5087
5265
|
key: "mainBranch",
|
|
5088
5266
|
configKey: "mainBranch",
|
package/package.json
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
You are a single agent in a sandbox on branch `feature/<slug>`. You have **one ticket**. There is **no human to ask** — no follow-up questions are possible. Everything you need is in this prompt and in the repo. Work carefully, commit only green work, and stop when the ticket is done.
|
|
6
6
|
|
|
7
|
+
## How you run
|
|
8
|
+
|
|
9
|
+
You run **non-interactively** (`claude --print`), for up to a few fresh iterations against the same worktree:
|
|
10
|
+
|
|
11
|
+
- **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. Never end your turn to wait on a background command; run long commands (dependency installs, full test suites) in the foreground with a generous timeout, or poll a backgrounded command to completion *within* the same turn.
|
|
12
|
+
- **You may not be the first iteration.** A previous iteration may have left commits or uncommitted work on this branch. Check `git status` and `git log` before starting; continue the work, don't redo it.
|
|
13
|
+
- **Signal completion.** When the ticket is fully done — every acceptance criterion verified, self-review finished, all work committed — print exactly `<promise>COMPLETE</promise>` as the last line of your final message. Do the same after writing `BLOCKED.md` (see hard rules). Without the signal, the harness assumes you were cut off and spends another iteration.
|
|
14
|
+
|
|
7
15
|
## The ticket
|
|
8
16
|
|
|
9
17
|
```json
|
|
@@ -39,4 +47,4 @@ You are a single agent in a sandbox on branch `feature/<slug>`. You have **one t
|
|
|
39
47
|
|
|
40
48
|
- **Never expand scope beyond the ticket.** If you notice adjacent work, worthwhile refactors, or another ticket's territory, leave it. Note it in your final commit body if it matters; do not do it.
|
|
41
49
|
- **No questions, no guessing into the void.** Resolve ambiguity from the ticket context and the code. If two readings both satisfy the acceptance criteria, take the smaller one.
|
|
42
|
-
- **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.
|
|
50
|
+
- **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, print `<promise>COMPLETE</promise>`, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.
|