runcastle 1.0.23 → 1.0.24

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/index.js CHANGED
@@ -104,10 +104,14 @@ var init_config = __esm(() => {
104
104
  mainBranch: z.string().default("main"),
105
105
  sandboxImage: z.string().optional(),
106
106
  burnConcurrency: z.number().int().min(1).max(8).default(3),
107
+ burnCpus: z.number().positive().max(256).optional(),
108
+ burnGuard: z.boolean().default(true),
107
109
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
108
110
  burnAttempts: z.number().int().min(1).max(5).default(3),
109
111
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
110
112
  setupCommand: z.string().optional(),
113
+ verifyCommands: z.string().optional(),
114
+ knownFailures: z.string().optional(),
111
115
  burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
112
116
  }));
113
117
  });
@@ -153,8 +157,17 @@ function loadConfig(env = process.env) {
153
157
  if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
154
158
  overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
155
159
  }
160
+ if (env.RUNCASTLE_BURN_CPUS)
161
+ overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
162
+ if (env.RUNCASTLE_BURN_GUARD !== undefined) {
163
+ overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
164
+ }
156
165
  if (env.RUNCASTLE_SETUP_COMMAND)
157
166
  overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
167
+ if (env.RUNCASTLE_VERIFY_COMMANDS)
168
+ overrides.verifyCommands = env.RUNCASTLE_VERIFY_COMMANDS;
169
+ if (env.RUNCASTLE_KNOWN_FAILURES)
170
+ overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
158
171
  if (env.RUNCASTLE_BURN_WORKSPACE)
159
172
  overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
160
173
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
@@ -659,6 +672,72 @@ var init_migrate = __esm(() => {
659
672
  // src/util/resolve-executable.ts
660
673
  import { existsSync as existsSync3 } from "fs";
661
674
  import { win32, posix } from "path";
675
+ function wellKnownBinDirs(opts = {}) {
676
+ const env = opts.env ?? process.env;
677
+ const isWin = (opts.platform ?? process.platform) === "win32";
678
+ const dirs = [];
679
+ if (isWin) {
680
+ const home2 = env.USERPROFILE;
681
+ if (home2) {
682
+ dirs.push(win32.join(home2, ".local", "bin"), win32.join(home2, ".bun", "bin"));
683
+ }
684
+ if (env.APPDATA)
685
+ dirs.push(win32.join(env.APPDATA, "npm"));
686
+ if (env.LOCALAPPDATA)
687
+ dirs.push(win32.join(env.LOCALAPPDATA, "Programs", "claude"));
688
+ return dirs;
689
+ }
690
+ const home = env.HOME;
691
+ if (home) {
692
+ dirs.push(posix.join(home, ".local", "bin"), posix.join(home, ".bun", "bin"), posix.join(home, ".npm-global", "bin"));
693
+ }
694
+ dirs.push("/usr/local/bin", "/opt/homebrew/bin");
695
+ return dirs;
696
+ }
697
+ function resolveTool(name, opts = {}) {
698
+ const env = opts.env ?? process.env;
699
+ const overrideKey = BIN_OVERRIDE_ENV[name];
700
+ const override = opts.override ?? (overrideKey ? env[overrideKey] : undefined);
701
+ const extraDirs = opts.extraDirs ?? wellKnownBinDirs({ ...opts.platform ? { platform: opts.platform } : {}, env });
702
+ return resolveExecutable(name, { ...opts, extraDirs, ...override ? { override } : {} });
703
+ }
704
+ function spawnTargetFor(resolved, args) {
705
+ if (/\.(cmd|bat)$/i.test(resolved)) {
706
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", resolved, ...args] };
707
+ }
708
+ if (/\.ps1$/i.test(resolved)) {
709
+ return {
710
+ file: "powershell.exe",
711
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args]
712
+ };
713
+ }
714
+ return { file: resolved, args };
715
+ }
716
+ function resolveSpawnTarget(name, args) {
717
+ return spawnTargetFor(resolveTool(name), args);
718
+ }
719
+ function isBareName(cmd) {
720
+ return !cmd.includes("/") && !cmd.includes("\\");
721
+ }
722
+ function explainSpawnFailure(cmd, raw) {
723
+ const detail = raw.trim();
724
+ if (!isBareName(cmd)) {
725
+ return `Could not launch ${cmd}${detail ? ` \u2014 ${detail}` : ""}`;
726
+ }
727
+ const overrideKey = BIN_OVERRIDE_ENV[cmd];
728
+ const lines = [
729
+ `Could not launch \`${cmd}\`: not on the PATH this runcastle server inherited, nor in the usual install locations (~/.local/bin, npm global, ~/.bun/bin).`,
730
+ `A terminal where \`${cmd}\` works does not prove the server can see it: the server's PATH was captured when it started.`,
731
+ `Fix: quit runcastle and start it again from a terminal where \`${cmd} --version\` works.`
732
+ ];
733
+ if (overrideKey) {
734
+ lines.push(`If that fails, set ${overrideKey} to the full path (\`where.exe ${cmd}\` / \`which ${cmd}\`) and restart.`);
735
+ }
736
+ if (detail)
737
+ lines.push(`(underlying error: ${detail})`);
738
+ return lines.join(`
739
+ `);
740
+ }
662
741
  function resolveExecutable(name, opts = {}) {
663
742
  const exists = opts.exists ?? existsSync3;
664
743
  if (opts.override && exists(opts.override))
@@ -666,22 +745,28 @@ function resolveExecutable(name, opts = {}) {
666
745
  const isWin = (opts.platform ?? process.platform) === "win32";
667
746
  const exts = opts.exts ?? (isWin ? WIN_EXTS : [""]);
668
747
  const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
669
- const dirs = pathEnv.split(isWin ? ";" : ":");
670
748
  const join5 = isWin ? win32.join : posix.join;
671
- for (const dir of dirs) {
672
- if (!dir)
673
- continue;
674
- for (const ext of exts) {
675
- const candidate = join5(dir, `${name}${ext}`);
676
- if (exists(candidate))
677
- return candidate;
749
+ const scan = (dirs) => {
750
+ for (const dir of dirs) {
751
+ if (!dir)
752
+ continue;
753
+ for (const ext of exts) {
754
+ const candidate = join5(dir, `${name}${ext}`);
755
+ if (exists(candidate))
756
+ return candidate;
757
+ }
678
758
  }
679
- }
680
- return name;
759
+ return null;
760
+ };
761
+ return scan(pathEnv.split(isWin ? ";" : ":")) ?? scan(opts.extraDirs ?? []) ?? name;
681
762
  }
682
- var WIN_EXTS;
763
+ var WIN_EXTS, BIN_OVERRIDE_ENV;
683
764
  var init_resolve_executable = __esm(() => {
684
- WIN_EXTS = [".exe", ".cmd", ".bat", ""];
765
+ WIN_EXTS = [".exe", ".cmd", ".bat", ".ps1", ""];
766
+ BIN_OVERRIDE_ENV = {
767
+ claude: "RUNCASTLE_CLAUDE_BIN",
768
+ node: "RUNCASTLE_NODE_BIN"
769
+ };
685
770
  });
686
771
 
687
772
  // src/pty/pty-sidecar.ts
@@ -698,7 +783,7 @@ function resolveNodeExecutable() {
698
783
  return override;
699
784
  if (!isBun())
700
785
  return process.execPath;
701
- return resolveExecutable("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
786
+ return resolveTool("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
702
787
  }
703
788
  function resolveNodePtyEntry() {
704
789
  try {
@@ -766,11 +851,17 @@ function createSidecarPtySession(cmd, args, opts) {
766
851
  case "exit":
767
852
  fireExit(typeof msg.code === "number" ? msg.code : 0, msg.signal ?? undefined);
768
853
  break;
769
- case "error":
770
- process.stderr.write(`[pty-sidecar] host error: ${msg.message ?? "unknown"}
854
+ case "error": {
855
+ const why = explainSpawnFailure(cmd, msg.message ?? "unknown");
856
+ process.stderr.write(`[pty-sidecar] host error: ${why}
771
857
  `);
858
+ emitData(Buffer.from(`\r
859
+ ${why.replace(/\n/g, `\r
860
+ `)}\r
861
+ `, "utf8"));
772
862
  fireExit(1);
773
863
  break;
864
+ }
774
865
  }
775
866
  }
776
867
  });
@@ -779,8 +870,13 @@ function createSidecarPtySession(cmd, args, opts) {
779
870
  process.stderr.write(chunk);
780
871
  });
781
872
  child.on("error", (err) => {
782
- process.stderr.write(`[pty-sidecar] failed to spawn node host: ${err.message}
873
+ const why = explainSpawnFailure(nodeExe, err.message);
874
+ process.stderr.write(`[pty-sidecar] failed to spawn node host: ${why}
783
875
  `);
876
+ emitData(Buffer.from(`\r
877
+ ${why.replace(/\n/g, `\r
878
+ `)}\r
879
+ `, "utf8"));
784
880
  fireExit(1);
785
881
  });
786
882
  child.on("exit", (code, signal) => {
@@ -915,14 +1011,19 @@ function createPtySession(cmd, args, opts) {
915
1011
  }
916
1012
  function createNativePtySession(cmd, args, opts) {
917
1013
  const pty = loadNodePty();
918
- const proc = pty.spawn(cmd, args, {
919
- name: "xterm-256color",
920
- cols: opts.cols ?? 80,
921
- rows: opts.rows ?? 24,
922
- cwd: opts.cwd,
923
- env: cleanEnv(opts.env),
924
- useConpty: opts.useConpty ?? true
925
- });
1014
+ let proc;
1015
+ try {
1016
+ proc = pty.spawn(cmd, args, {
1017
+ name: "xterm-256color",
1018
+ cols: opts.cols ?? 80,
1019
+ rows: opts.rows ?? 24,
1020
+ cwd: opts.cwd,
1021
+ env: cleanEnv(opts.env),
1022
+ useConpty: opts.useConpty ?? true
1023
+ });
1024
+ } catch (err) {
1025
+ throw new Error(explainSpawnFailure(cmd, err instanceof Error ? err.message : String(err)));
1026
+ }
926
1027
  let killed = false;
927
1028
  proc.onExit(() => {
928
1029
  killed = true;
@@ -957,6 +1058,7 @@ function createNativePtySession(cmd, args, opts) {
957
1058
  }
958
1059
  var cached = null, BACKEND_LOGGED;
959
1060
  var init_pty = __esm(() => {
1061
+ init_resolve_executable();
960
1062
  init_pty_sidecar();
961
1063
  BACKEND_LOGGED = Symbol.for("runcastle.pty.backend.logged");
962
1064
  });
@@ -1697,7 +1799,7 @@ var init_feature_docs = __esm(() => {
1697
1799
  });
1698
1800
 
1699
1801
  // src/services/tickets.ts
1700
- import { asc as asc3, eq as eq5 } from "drizzle-orm";
1802
+ import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
1701
1803
  function rowToTicket(row) {
1702
1804
  return {
1703
1805
  id: row.id,
@@ -1807,6 +1909,19 @@ function cancelTicket(ctx, id, reason) {
1807
1909
  });
1808
1910
  return getTicket(ctx, id);
1809
1911
  }
1912
+ function sweepOrphanedBurning(ctx, featureId, reason) {
1913
+ const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
1914
+ for (const t of orphaned) {
1915
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
1916
+ emit(ctx, featureId, {
1917
+ type: "ticket.failed",
1918
+ message: `ticket ${t.seq} failed: ${reason}`,
1919
+ ticketId: t.id,
1920
+ data: { error: reason, orphaned: true }
1921
+ });
1922
+ }
1923
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
1924
+ }
1810
1925
  function updateTicket(ctx, id, patch) {
1811
1926
  const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1812
1927
  if (!current2)
@@ -2813,6 +2928,89 @@ var init_agent_stream = __esm(() => {
2813
2928
  transcripts = new Map;
2814
2929
  });
2815
2930
 
2931
+ // src/workflows/burn-guard.ts
2932
+ function renderGuardScript(rules = GUARD_RULES) {
2933
+ const lines = [
2934
+ "#!/bin/sh",
2935
+ "# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
2936
+ "set -u",
2937
+ "payload=$(cat 2>/dev/null || true)",
2938
+ '[ -n "$payload" ] || exit 0',
2939
+ "command -v jq >/dev/null 2>&1 || exit 0",
2940
+ 'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
2941
+ '[ "$tool" = "Bash" ] || exit 0',
2942
+ 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
2943
+ '[ -n "$cmd" ] || exit 0',
2944
+ "",
2945
+ "# Match against the command with quoted spans blanked, so an argument is",
2946
+ '# never read as a command: `grep -rn "git stash" docs/` searches for a',
2947
+ "# string, it does not run one.",
2948
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
2949
+ "",
2950
+ "deny() {",
2951
+ ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
2952
+ " exit 0",
2953
+ "}",
2954
+ ""
2955
+ ];
2956
+ for (const rule of rules) {
2957
+ lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
2958
+ }
2959
+ lines.push("exit 0", "");
2960
+ return lines.join(`
2961
+ `);
2962
+ }
2963
+ function shSingleQuote(value) {
2964
+ return `'${value.split("'").join(`'\\''`)}'`;
2965
+ }
2966
+ function renderGuardSettings() {
2967
+ return {
2968
+ hooks: {
2969
+ PreToolUse: [
2970
+ {
2971
+ matcher: "Bash",
2972
+ hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
2973
+ }
2974
+ ]
2975
+ }
2976
+ };
2977
+ }
2978
+ function buildGuardInstallCommand() {
2979
+ const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
2980
+ const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
2981
+ return [
2982
+ `mkdir -p "$HOME/.claude/hooks"`,
2983
+ `printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
2984
+ `chmod +x "${GUARD_SCRIPT_PATH}"`,
2985
+ `printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
2986
+ ].join(" && ");
2987
+ }
2988
+ var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
2989
+ var init_burn_guard = __esm(() => {
2990
+ GUARD_RULES = [
2991
+ {
2992
+ id: "no-stash",
2993
+ pattern: `${CMD_START}git[[:space:]]+stash`,
2994
+ reason: "git stash is blocked in burns: stashed work is invisible to the orchestrator and unrecoverable if this process dies, and only commits survive. To compare against the pre-change state use `git show HEAD:<path>`, or the pre-existing-failure baseline in your prompt."
2995
+ },
2996
+ {
2997
+ id: "no-serialised-tests",
2998
+ pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
2999
+ reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
3000
+ },
3001
+ {
3002
+ id: "no-interpreter-heredoc-edit",
3003
+ pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
3004
+ reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
3005
+ },
3006
+ {
3007
+ id: "no-cat-heredoc-edit",
3008
+ pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
3009
+ reason: "Do not write files with `cat > file <<EOF`. Use the Write tool for a new file, or Edit to change part of an existing one."
3010
+ }
3011
+ ];
3012
+ });
3013
+
2816
3014
  // src/workflows/ticket-burner.ts
2817
3015
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2818
3016
  import { dirname as dirname3, join as join9 } from "path";
@@ -3019,6 +3217,24 @@ function buildWorkspaceNotes(mode) {
3019
3217
  ].join(`
3020
3218
  `);
3021
3219
  }
3220
+ function buildVerifyNotes(config2) {
3221
+ const commands = config2.verifyCommands?.trim();
3222
+ const failures = config2.knownFailures?.trim();
3223
+ const out = [];
3224
+ if (commands) {
3225
+ out.push("Verify with exactly these commands \u2014 they are correct for this repo, so do not go looking for alternatives or guess at workspace/package filter names:", "", "```", commands, "```");
3226
+ } else {
3227
+ out.push("No verification commands are configured, so derive them ONCE from the repo (root `package.json` scripts, the workspace's own manifest, CI config) before your first test run \u2014 reading a manifest is free, discovering a filter name by running the wrong suite is not. Write the working commands into your notes and reuse them verbatim for the rest of the ticket.");
3228
+ }
3229
+ out.push("");
3230
+ if (failures) {
3231
+ out.push("These tests ALREADY fail on this repo before you touch anything:", "", "```", failures, "```", "", "Treat that as your baseline \u2014 do NOT spend a run establishing it yourself. Only a failure outside this set is yours to fix; if you hit one that looks pre-existing but is not listed, confirm it on a single targeted run of that one test, never a whole-suite re-run.");
3232
+ } else {
3233
+ out.push("No pre-existing-failure baseline is configured. If the suite is already red, capture the baseline ONCE \u2014 the first full run you do, before your changes land, is the baseline \u2014 and compare every later run against those saved results. Never re-run a whole suite just to re-establish what was already failing.");
3234
+ }
3235
+ return out.join(`
3236
+ `);
3237
+ }
3022
3238
  function readRepoToolchain(repoPath) {
3023
3239
  const pkgPath = join9(repoPath, "package.json");
3024
3240
  const hasPackageJson = existsSync10(pkgPath);
@@ -3075,6 +3291,63 @@ function createStreamThrottle(emit2, opts = {}) {
3075
3291
  };
3076
3292
  return { onEvent, flush: flushText };
3077
3293
  }
3294
+ function normalizeCommandForClassification(command) {
3295
+ return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3296
+ }
3297
+ function classifyToolCall(name, args) {
3298
+ const byName = TOOL_NAME_CATEGORY[name];
3299
+ if (byName)
3300
+ return byName;
3301
+ if (name !== "Bash")
3302
+ return "other";
3303
+ const normalized = normalizeCommandForClassification(args);
3304
+ for (const [category, pattern] of BASH_PATTERNS) {
3305
+ if (pattern.test(normalized))
3306
+ return category;
3307
+ }
3308
+ return "other";
3309
+ }
3310
+ function createToolTimer() {
3311
+ const byCategory = {};
3312
+ let calls = 0;
3313
+ let totalMs = 0;
3314
+ let pending = null;
3315
+ let iteration = null;
3316
+ const charge = (category, ms) => {
3317
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3318
+ slot.ms += ms;
3319
+ totalMs += ms;
3320
+ };
3321
+ const onEvent = (event) => {
3322
+ if (event.type === "raw")
3323
+ return;
3324
+ const at = event.timestamp.getTime();
3325
+ if (iteration !== null && event.iteration !== iteration)
3326
+ pending = null;
3327
+ iteration = event.iteration;
3328
+ if (pending) {
3329
+ const gap = at - pending.at;
3330
+ if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
3331
+ charge(pending.category, gap);
3332
+ }
3333
+ if (event.type === "toolCall") {
3334
+ const category = classifyToolCall(event.name, event.formattedArgs ?? "");
3335
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3336
+ slot.calls += 1;
3337
+ calls += 1;
3338
+ pending = { category, at };
3339
+ } else {
3340
+ pending = { category: "model", at };
3341
+ }
3342
+ };
3343
+ return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
3344
+ }
3345
+ function formatTimingSummary(s) {
3346
+ if (s.totalMs === 0)
3347
+ return `${s.calls} tool call(s), no measurable time`;
3348
+ const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
3349
+ return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
3350
+ }
3078
3351
  function interpretRunResult(result, blockedContent) {
3079
3352
  const commits = result.commits.map((c) => c.sha);
3080
3353
  if (commits.length > 0)
@@ -3390,10 +3663,7 @@ function buildBurnAgent(config2, token, model) {
3390
3663
  return agent;
3391
3664
  }
3392
3665
  function selectSandbox(config2, mounts = []) {
3393
- const imageOpts = {
3394
- imageName: resolveSandboxImage(config2),
3395
- ...mounts.length > 0 ? { mounts } : {}
3396
- };
3666
+ const imageOpts = buildSandboxOptions(config2, mounts);
3397
3667
  switch (config2.sandbox) {
3398
3668
  case "docker":
3399
3669
  return docker(imageOpts);
@@ -3403,6 +3673,13 @@ function selectSandbox(config2, mounts = []) {
3403
3673
  return noSandbox();
3404
3674
  }
3405
3675
  }
3676
+ function buildSandboxOptions(config2, mounts = []) {
3677
+ return {
3678
+ imageName: resolveSandboxImage(config2),
3679
+ ...mounts.length > 0 ? { mounts } : {},
3680
+ ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
3681
+ };
3682
+ }
3406
3683
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3407
3684
  const { project, feature } = ctx;
3408
3685
  const workspaceMode = resolveBurnWorkspaceMode(config2);
@@ -3412,11 +3689,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3412
3689
  const featureBrief = buildFeatureBrief(feature);
3413
3690
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3414
3691
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3692
+ const verifyNotes = buildVerifyNotes(config2);
3415
3693
  const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3416
3694
  TICKET_JSON: ticketJson,
3417
3695
  FEATURE_BRIEF: featureBrief,
3418
3696
  DOCS_DIGEST: docsDigest,
3419
3697
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3698
+ VERIFY_NOTES: verifyNotes,
3420
3699
  WORKSPACE_NOTES: workspaceNotes
3421
3700
  });
3422
3701
  const toolchain = readRepoToolchain(project.repoPath);
@@ -3430,12 +3709,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3430
3709
  mounts.push(mount);
3431
3710
  }
3432
3711
  }
3712
+ const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3713
+ const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3433
3714
  mkdirSync4(logsDir(), { recursive: true });
3434
3715
  const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3435
3716
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3436
3717
  beginTranscript(ticket.id);
3718
+ const timer = createToolTimer();
3437
3719
  const onStreamEvent = (event) => {
3438
3720
  throttle.onEvent(event);
3721
+ timer.onEvent(event);
3439
3722
  if (event.type === "text") {
3440
3723
  appendTranscript(ticket.id, { kind: "text", text: event.message });
3441
3724
  } else if (event.type === "toolCall") {
@@ -3480,12 +3763,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3480
3763
  FEATURE_BRIEF: featureBrief,
3481
3764
  DOCS_DIGEST: docsDigest,
3482
3765
  WORKSPACE_NOTES: workspaceNotes,
3766
+ VERIFY_NOTES: verifyNotes,
3483
3767
  FEATURE_BRANCH: feature.branch,
3484
3768
  CONFLICT_FILES: buildConflictFilesBlock(input.files),
3485
3769
  OTHER_SIDE: buildOtherSideBlock(otherSide),
3486
3770
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3487
3771
  });
3488
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3772
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
3489
3773
  appendTranscript(ticket.id, {
3490
3774
  kind: "text",
3491
3775
  text: `
@@ -3615,7 +3899,7 @@ ${blocked.trim()}`
3615
3899
  let tempBranch = "";
3616
3900
  for (let attempt = 1;; attempt++) {
3617
3901
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3618
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3902
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
3619
3903
  if (hookCommand && attempt === 1) {
3620
3904
  ctx.emitEvent({
3621
3905
  type: "burn.setup",
@@ -3750,6 +4034,15 @@ ${blocked.trim()}` };
3750
4034
  activeTicketAborts.delete(ticket.id);
3751
4035
  throttle.flush();
3752
4036
  endTranscript(ticket.id);
4037
+ const timing = timer.summary();
4038
+ if (timing.calls > 0) {
4039
+ ctx.emitEvent({
4040
+ type: "ticket.timing",
4041
+ message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
4042
+ ticketId: ticket.id,
4043
+ data: timing
4044
+ });
4045
+ }
3753
4046
  }
3754
4047
  }
3755
4048
  function resolveBurnDeps(ctx) {
@@ -3778,7 +4071,7 @@ function readTokenFromEnvFile(path) {
3778
4071
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3779
4072
  return token && token.length > 0 ? token : undefined;
3780
4073
  }
3781
- 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", BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
4074
+ 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", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3782
4075
  var init_ticket_burner = __esm(() => {
3783
4076
  init_src();
3784
4077
  init_config_load();
@@ -3786,12 +4079,35 @@ var init_ticket_burner = __esm(() => {
3786
4079
  init_skills_root();
3787
4080
  init_agent_stream();
3788
4081
  init_git();
4082
+ init_burn_guard();
3789
4083
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3790
4084
  PM_CACHE_SANDBOX_PATHS = {
3791
4085
  bun: "~/.bun/install/cache",
3792
4086
  yarn: "~/.cache/yarn",
3793
4087
  npm: "~/.npm"
3794
4088
  };
4089
+ RUN = String.raw`(?![\w./-])`;
4090
+ BASH_PATTERNS = [
4091
+ ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
4092
+ ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4093
+ ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4094
+ ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4095
+ ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4096
+ ["git", /\bgit\b/],
4097
+ ["search", /\b(grep|rg|ag|find)\b/],
4098
+ ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4099
+ ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4100
+ ];
4101
+ TOOL_NAME_CATEGORY = {
4102
+ Read: "file-read",
4103
+ NotebookRead: "file-read",
4104
+ Grep: "search",
4105
+ Glob: "search",
4106
+ Edit: "file-edit",
4107
+ Write: "file-edit",
4108
+ NotebookEdit: "file-edit"
4109
+ };
4110
+ MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
3795
4111
  BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
3796
4112
  WORKTREE_REMOVAL_FAILURES = [
3797
4113
  /failed to delete/i,
@@ -4092,7 +4408,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4092
4408
  },
4093
4409
  signal: controller.signal
4094
4410
  };
4095
- const done = executeRun(ctx, runId, featureId, def.run(wctx), controller, async () => {
4411
+ const done = executeRun(ctx, runId, featureId, workflowId, def.run(wctx), controller, async () => {
4096
4412
  if (talkDetached)
4097
4413
  await reattachWorktree(talkWorktree, feature.branch);
4098
4414
  });
@@ -4101,7 +4417,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4101
4417
  function cancelRun(runId) {
4102
4418
  controllers.get(runId)?.abort();
4103
4419
  }
4104
- async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup) {
4420
+ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4105
4421
  let status = "failed";
4106
4422
  let summary = "run failed";
4107
4423
  try {
@@ -4122,6 +4438,9 @@ async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup
4122
4438
  }
4123
4439
  ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
4124
4440
  releaseForSession(ctx, runId);
4441
+ if (workflowClaimsFeatureBranch(workflow2)) {
4442
+ sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
4443
+ }
4125
4444
  emit(ctx, featureId, {
4126
4445
  type: "run.finished",
4127
4446
  message: `run ${status}: ${summary}`,
@@ -4263,7 +4582,7 @@ function advance(ctx, featureId) {
4263
4582
  async function burn(ctx, featureId, opts = {}) {
4264
4583
  const feature = getFeatureRow(ctx, featureId);
4265
4584
  const running = hasActiveRun(ctx, featureId);
4266
- const tickets2 = listByFeature2(ctx, featureId);
4585
+ let tickets2 = listByFeature2(ctx, featureId);
4267
4586
  const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4268
4587
  const restarting = feature.phase === "implementation" && !running;
4269
4588
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
@@ -4281,6 +4600,8 @@ async function burn(ctx, featureId, opts = {}) {
4281
4600
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
4282
4601
  }
4283
4602
  if (restarting) {
4603
+ sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
4604
+ tickets2 = listByFeature2(ctx, featureId);
4284
4605
  const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
4285
4606
  for (const t of failed) {
4286
4607
  updateTicket(ctx, t.id, { status: "pending", error: null });
@@ -5384,7 +5705,7 @@ var init_artifacts = __esm(() => {
5384
5705
  import { existsSync as existsSync13 } from "fs";
5385
5706
  import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5386
5707
  import { fileURLToPath as fileURLToPath5 } from "url";
5387
- import { and as and5, eq as eq8 } from "drizzle-orm";
5708
+ import { and as and6, eq as eq8 } from "drizzle-orm";
5388
5709
  function ptyExitMessage(exitCode) {
5389
5710
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5390
5711
  return `terminal exited (code ${label})`;
@@ -5410,14 +5731,10 @@ function buildClaudeArgs(input) {
5410
5731
  ];
5411
5732
  }
5412
5733
  function resolveClaudeExecutable() {
5413
- return resolveExecutable("claude", { override: process.env.RUNCASTLE_CLAUDE_BIN });
5734
+ return resolveTool("claude");
5414
5735
  }
5415
5736
  function claudeSpawnTarget(claudeArgs) {
5416
- const exe = resolveClaudeExecutable();
5417
- if (/\.(cmd|bat)$/i.test(exe)) {
5418
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", exe, ...claudeArgs] };
5419
- }
5420
- return { file: exe, args: claudeArgs };
5737
+ return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5421
5738
  }
5422
5739
  function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5423
5740
  const rel = join13("packages", "skills", "packs", "runcastle");
@@ -5445,7 +5762,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5445
5762
  `)}`);
5446
5763
  }
5447
5764
  function activeRunFor(ctx, featureId) {
5448
- const row = ctx.db.select().from(runs).where(and5(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
5765
+ const row = ctx.db.select().from(runs).where(and6(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
5449
5766
  return row ? rowToRun(row) : null;
5450
5767
  }
5451
5768
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6332,6 +6649,14 @@ var init_settings = __esm(() => {
6332
6649
  valueSchema: z9.number().int().min(0).max(3),
6333
6650
  parseEnv: (raw) => Number(raw)
6334
6651
  },
6652
+ {
6653
+ key: "burnCpus",
6654
+ configKey: "burnCpus",
6655
+ envVar: "RUNCASTLE_BURN_CPUS",
6656
+ restartRequired: false,
6657
+ valueSchema: z9.number().positive().max(256),
6658
+ parseEnv: (raw) => Number(raw)
6659
+ },
6335
6660
  {
6336
6661
  key: "setupCommand",
6337
6662
  configKey: "setupCommand",
@@ -6340,6 +6665,22 @@ var init_settings = __esm(() => {
6340
6665
  valueSchema: z9.string().min(1),
6341
6666
  parseEnv: idEnv
6342
6667
  },
6668
+ {
6669
+ key: "verifyCommands",
6670
+ configKey: "verifyCommands",
6671
+ envVar: "RUNCASTLE_VERIFY_COMMANDS",
6672
+ restartRequired: false,
6673
+ valueSchema: z9.string().min(1),
6674
+ parseEnv: idEnv
6675
+ },
6676
+ {
6677
+ key: "knownFailures",
6678
+ configKey: "knownFailures",
6679
+ envVar: "RUNCASTLE_KNOWN_FAILURES",
6680
+ restartRequired: false,
6681
+ valueSchema: z9.string().min(1),
6682
+ parseEnv: idEnv
6683
+ },
6343
6684
  {
6344
6685
  key: "mainBranch",
6345
6686
  configKey: "mainBranch",
@@ -6538,10 +6879,7 @@ var init_doctor = __esm(() => {
6538
6879
  import { spawn as spawn2 } from "child_process";
6539
6880
  function createSystemExec(opts = {}) {
6540
6881
  return (command, args) => new Promise((resolve9) => {
6541
- const resolved = resolveExecutable(command);
6542
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6543
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6544
- const spawnArgs = isShim ? ["/c", resolved, ...args] : args;
6882
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
6545
6883
  let stdout = "";
6546
6884
  let stderr = "";
6547
6885
  const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
@@ -6692,12 +7030,29 @@ function prepareSandboxBuildContext() {
6692
7030
  }
6693
7031
  function createTokenVerifier(exec) {
6694
7032
  return async (token) => {
7033
+ if (token.length < 8) {
7034
+ return {
7035
+ valid: false,
7036
+ detail: "token looks malformed (too short) \u2014 saved to ~/.runcastle/.env anyway",
7037
+ fix: "Re-run `claude setup-token` and paste the whole line it prints (it starts with `sk-ant-oat`)."
7038
+ };
7039
+ }
6695
7040
  const out = await exec("claude", ["--version"]);
6696
- if (!(out.ok && out.code === 0)) {
6697
- return { valid: false, detail: "claude CLI not found \u2014 cannot verify the token" };
7041
+ if (!out.ok) {
7042
+ return {
7043
+ valid: false,
7044
+ detail: "Token saved to ~/.runcastle/.env, but runcastle could not launch `claude` to verify it. " + "That is a PATH problem in this server process, not a missing install \u2014 a terminal that finds " + "`claude` proves nothing about the PATH runcastle was started with.",
7045
+ fix: "Quit runcastle and start it again from a terminal where `claude --version` works. " + "If it still fails, pin the path: set RUNCASTLE_CLAUDE_BIN to the full path from " + "`where.exe claude` (Windows) or `which claude` (macOS/Linux), then restart runcastle."
7046
+ };
6698
7047
  }
6699
- if (token.length < 8) {
6700
- return { valid: false, detail: "token looks malformed (too short)" };
7048
+ if (out.code !== 0) {
7049
+ const why = (out.stderr.trim() || out.stdout.trim()).split(`
7050
+ `)[0] ?? "";
7051
+ return {
7052
+ valid: false,
7053
+ detail: `Token saved to ~/.runcastle/.env, but \`claude --version\` exited ${out.code}` + `${why ? `: ${why}` : " with no output"}.`,
7054
+ fix: "Run `claude --version` yourself and fix what it reports \u2014 the Claude Code install is broken, not the token."
7055
+ };
6701
7056
  }
6702
7057
  return { valid: true, detail: "token captured to ~/.runcastle/.env" };
6703
7058
  };
@@ -6749,10 +7104,7 @@ var init_setup2 = __esm(() => {
6749
7104
  const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
6750
7105
  const sessionId = newId("setup");
6751
7106
  const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
6752
- const resolved = resolveExecutable(spec.cmd);
6753
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6754
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6755
- const args = isShim ? ["/c", resolved, ...spec.args] : spec.args;
7107
+ const { file, args } = resolveSpawnTarget(spec.cmd, spec.args);
6756
7108
  ptyRegistry().create({
6757
7109
  sessionId,
6758
7110
  cmd: file,
@@ -6882,12 +7234,22 @@ import * as z12 from "zod";
6882
7234
  var ticketRouter;
6883
7235
  var init_ticket = __esm(() => {
6884
7236
  init_features();
7237
+ init_repo();
6885
7238
  init_tickets();
6886
7239
  init_ticket_burner();
6887
7240
  init_context();
6888
7241
  ticketRouter = router({
6889
7242
  retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
6890
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ input }) => ({ stopped: stopTicketRun(input.ticketId) })),
7243
+ stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
7244
+ if (stopTicketRun(input.ticketId))
7245
+ return { stopped: true, swept: false };
7246
+ const ticket = getTicket(ctx, input.ticketId);
7247
+ if (ticket.status === "burning" && !hasActiveRun(ctx, ticket.featureId)) {
7248
+ sweepOrphanedBurning(ctx, ticket.featureId, "orphaned \u2014 the run that was burning it is gone");
7249
+ return { stopped: false, swept: true };
7250
+ }
7251
+ return { stopped: false, swept: false };
7252
+ }),
6891
7253
  cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
6892
7254
  });
6893
7255
  });
@@ -6928,6 +7290,7 @@ async function reconcileStaleRuns(ctx) {
6928
7290
  continue;
6929
7291
  ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
6930
7292
  const released = releaseForSession(ctx, run3.id);
7293
+ const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
6931
7294
  if (workflowClaimsFeatureBranch(run3.workflow)) {
6932
7295
  const feature = tryGetFeature(ctx, run3.featureId);
6933
7296
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
@@ -6944,7 +7307,8 @@ async function reconcileStaleRuns(ctx) {
6944
7307
  data: {
6945
7308
  runId: run3.id,
6946
7309
  workflow: run3.workflow,
6947
- releasedWaypointIds: released.map((w) => w.id)
7310
+ releasedWaypointIds: released.map((w) => w.id),
7311
+ sweptTicketSeqs: swept.map((t2) => t2.seq)
6948
7312
  }
6949
7313
  });
6950
7314
  reconciled.push(run3);
@@ -6962,6 +7326,7 @@ var init_reconcile_runs = __esm(() => {
6962
7326
  init_events();
6963
7327
  init_git();
6964
7328
  init_repo();
7329
+ init_tickets();
6965
7330
  init_waypoints();
6966
7331
  init_runner();
6967
7332
  });