runcastle 1.0.22 → 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)
@@ -2199,6 +2314,34 @@ function researchBranchName(slug, waypointSeq, unique) {
2199
2314
  function ticketBranchName(slug, ticketSeq, unique) {
2200
2315
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2201
2316
  }
2317
+ function burnWorktreePath(repoPath, branch) {
2318
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2319
+ }
2320
+ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2321
+ const path = burnWorktreePath(repoPath, branch);
2322
+ const attempts = Math.max(1, opts.attempts ?? 3);
2323
+ const delayMs = opts.delayMs ?? 750;
2324
+ const g = git(repoPath);
2325
+ for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2326
+ try {
2327
+ await g.raw(["worktree", "remove", "--force", path]);
2328
+ } catch {}
2329
+ if (existsSync7(path) && attempt < attempts)
2330
+ await sleep(delayMs);
2331
+ }
2332
+ if (existsSync7(path)) {
2333
+ try {
2334
+ rmSync(path, { recursive: true, force: true });
2335
+ } catch {}
2336
+ }
2337
+ try {
2338
+ await g.raw(["worktree", "prune"]);
2339
+ } catch {}
2340
+ return !existsSync7(path);
2341
+ }
2342
+ function sleep(ms) {
2343
+ return new Promise((done) => setTimeout(done, ms));
2344
+ }
2202
2345
  async function branchCommitsAhead(repoPath, base, branch) {
2203
2346
  try {
2204
2347
  const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
@@ -2785,6 +2928,89 @@ var init_agent_stream = __esm(() => {
2785
2928
  transcripts = new Map;
2786
2929
  });
2787
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
+
2788
3014
  // src/workflows/ticket-burner.ts
2789
3015
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2790
3016
  import { dirname as dirname3, join as join9 } from "path";
@@ -2991,6 +3217,24 @@ function buildWorkspaceNotes(mode) {
2991
3217
  ].join(`
2992
3218
  `);
2993
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
+ }
2994
3238
  function readRepoToolchain(repoPath) {
2995
3239
  const pkgPath = join9(repoPath, "package.json");
2996
3240
  const hasPackageJson = existsSync10(pkgPath);
@@ -3047,6 +3291,63 @@ function createStreamThrottle(emit2, opts = {}) {
3047
3291
  };
3048
3292
  return { onEvent, flush: flushText };
3049
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
+ }
3050
3351
  function interpretRunResult(result, blockedContent) {
3051
3352
  const commits = result.commits.map((c) => c.sha);
3052
3353
  if (commits.length > 0)
@@ -3057,6 +3358,12 @@ ${blockedContent.trim()}` };
3057
3358
  }
3058
3359
  return { status: "failed", error: "agent made no commits" };
3059
3360
  }
3361
+ function isWorktreeTeardownError(err) {
3362
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3363
+ if (!BURN_WORKTREE_PATH.test(msg))
3364
+ return false;
3365
+ return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
3366
+ }
3060
3367
  function isMergeConflictError(err) {
3061
3368
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
3062
3369
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
@@ -3356,10 +3663,7 @@ function buildBurnAgent(config2, token, model) {
3356
3663
  return agent;
3357
3664
  }
3358
3665
  function selectSandbox(config2, mounts = []) {
3359
- const imageOpts = {
3360
- imageName: resolveSandboxImage(config2),
3361
- ...mounts.length > 0 ? { mounts } : {}
3362
- };
3666
+ const imageOpts = buildSandboxOptions(config2, mounts);
3363
3667
  switch (config2.sandbox) {
3364
3668
  case "docker":
3365
3669
  return docker(imageOpts);
@@ -3369,6 +3673,13 @@ function selectSandbox(config2, mounts = []) {
3369
3673
  return noSandbox();
3370
3674
  }
3371
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
+ }
3372
3683
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3373
3684
  const { project, feature } = ctx;
3374
3685
  const workspaceMode = resolveBurnWorkspaceMode(config2);
@@ -3378,11 +3689,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3378
3689
  const featureBrief = buildFeatureBrief(feature);
3379
3690
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3380
3691
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3692
+ const verifyNotes = buildVerifyNotes(config2);
3381
3693
  const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3382
3694
  TICKET_JSON: ticketJson,
3383
3695
  FEATURE_BRIEF: featureBrief,
3384
3696
  DOCS_DIGEST: docsDigest,
3385
3697
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3698
+ VERIFY_NOTES: verifyNotes,
3386
3699
  WORKSPACE_NOTES: workspaceNotes
3387
3700
  });
3388
3701
  const toolchain = readRepoToolchain(project.repoPath);
@@ -3396,12 +3709,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3396
3709
  mounts.push(mount);
3397
3710
  }
3398
3711
  }
3712
+ const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3713
+ const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3399
3714
  mkdirSync4(logsDir(), { recursive: true });
3400
3715
  const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3401
3716
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3402
3717
  beginTranscript(ticket.id);
3718
+ const timer = createToolTimer();
3403
3719
  const onStreamEvent = (event) => {
3404
3720
  throttle.onEvent(event);
3721
+ timer.onEvent(event);
3405
3722
  if (event.type === "text") {
3406
3723
  appendTranscript(ticket.id, { kind: "text", text: event.message });
3407
3724
  } else if (event.type === "toolCall") {
@@ -3446,12 +3763,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3446
3763
  FEATURE_BRIEF: featureBrief,
3447
3764
  DOCS_DIGEST: docsDigest,
3448
3765
  WORKSPACE_NOTES: workspaceNotes,
3766
+ VERIFY_NOTES: verifyNotes,
3449
3767
  FEATURE_BRANCH: feature.branch,
3450
3768
  CONFLICT_FILES: buildConflictFilesBlock(input.files),
3451
3769
  OTHER_SIDE: buildOtherSideBlock(otherSide),
3452
3770
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3453
3771
  });
3454
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3772
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
3455
3773
  appendTranscript(ticket.id, {
3456
3774
  kind: "text",
3457
3775
  text: `
@@ -3485,13 +3803,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3485
3803
  } catch (err) {
3486
3804
  if (ctx.signal.aborted)
3487
3805
  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
- };
3806
+ const tip = await bestTip();
3807
+ const teardownOnly = !ticketAbort.signal.aborted && isWorktreeTeardownError(err) && tip === resolveBranch;
3808
+ if (!teardownOnly) {
3809
+ return {
3810
+ ok: false,
3811
+ branch: tip,
3812
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3813
+ };
3814
+ }
3815
+ await cleanupBurnWorktree(project.repoPath, resolveBranch);
3493
3816
  }
3494
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3817
+ const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
3495
3818
  if (blocked !== undefined && blocked.trim().length > 0) {
3496
3819
  return {
3497
3820
  ok: false,
@@ -3576,7 +3899,7 @@ ${blocked.trim()}`
3576
3899
  let tempBranch = "";
3577
3900
  for (let attempt = 1;; attempt++) {
3578
3901
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3579
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3902
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
3580
3903
  if (hookCommand && attempt === 1) {
3581
3904
  ctx.emitEvent({
3582
3905
  type: "burn.setup",
@@ -3626,6 +3949,18 @@ ${retryNotes}` : basePrompt,
3626
3949
  }
3627
3950
  };
3628
3951
  }
3952
+ if (isWorktreeTeardownError(err)) {
3953
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3954
+ if (salvaged.length > 0) {
3955
+ ctx.emitEvent({
3956
+ type: "burn.worktree.teardown-failed",
3957
+ message: `ticket ${ticket.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3958
+ ticketId: ticket.id,
3959
+ data: { branch: tempBranch, error: msg, cleanedUp: removed }
3960
+ });
3961
+ return await landChain(tempBranch, salvaged);
3962
+ }
3963
+ }
3629
3964
  if (isMergeConflictError(err)) {
3630
3965
  if (salvaged.length > 0) {
3631
3966
  preserveChain(tempBranch);
@@ -3699,6 +4034,15 @@ ${blocked.trim()}` };
3699
4034
  activeTicketAborts.delete(ticket.id);
3700
4035
  throttle.flush();
3701
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
+ }
3702
4046
  }
3703
4047
  }
3704
4048
  function resolveBurnDeps(ctx) {
@@ -3727,7 +4071,7 @@ function readTokenFromEnvFile(path) {
3727
4071
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3728
4072
  return token && token.length > 0 ? token : undefined;
3729
4073
  }
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;
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;
3731
4075
  var init_ticket_burner = __esm(() => {
3732
4076
  init_src();
3733
4077
  init_config_load();
@@ -3735,12 +4079,43 @@ var init_ticket_burner = __esm(() => {
3735
4079
  init_skills_root();
3736
4080
  init_agent_stream();
3737
4081
  init_git();
4082
+ init_burn_guard();
3738
4083
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3739
4084
  PM_CACHE_SANDBOX_PATHS = {
3740
4085
  bun: "~/.bun/install/cache",
3741
4086
  yarn: "~/.cache/yarn",
3742
4087
  npm: "~/.npm"
3743
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;
4111
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4112
+ WORKTREE_REMOVAL_FAILURES = [
4113
+ /failed to delete/i,
4114
+ /directory not empty|enotempty/i,
4115
+ /unable to (unlink|delete)/i,
4116
+ /is not a working tree/i,
4117
+ /resource busy|ebusy|eperm|eacces/i
4118
+ ];
3744
4119
  FATAL_ERROR_PATTERNS = [
3745
4120
  /invalid (api key|x-api-key)/i,
3746
4121
  /authentication|unauthorized|permission denied/i,
@@ -3878,16 +4253,26 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3878
4253
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3879
4254
  };
3880
4255
  let result;
4256
+ let salvaged = [];
3881
4257
  try {
3882
4258
  result = await run2(runOptions);
3883
4259
  } catch (err) {
3884
4260
  throttle.flush();
3885
4261
  if (ctx.signal.aborted)
3886
4262
  throw err;
3887
- return { status: "failed", error: err instanceof Error ? err.message : String(err) };
4263
+ const msg = err instanceof Error ? err.message : String(err);
4264
+ salvaged = isWorktreeTeardownError(err) ? await branchCommitsAhead(project.repoPath, feature.branch, tempBranch) : [];
4265
+ if (salvaged.length === 0)
4266
+ return { status: "failed", error: msg };
4267
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
4268
+ ctx.emitEvent({
4269
+ type: "burn.worktree.teardown-failed",
4270
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
4271
+ data: { tempBranch, error: msg, cleanedUp: removed }
4272
+ });
3888
4273
  }
3889
4274
  throttle.flush();
3890
- const outcome = interpretResearchResult(result, docRelPath);
4275
+ const outcome = result ? interpretResearchResult(result, docRelPath) : { status: "done", commits: salvaged, docRelPath };
3891
4276
  if (outcome.status !== "done")
3892
4277
  return outcome;
3893
4278
  const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
@@ -4023,7 +4408,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4023
4408
  },
4024
4409
  signal: controller.signal
4025
4410
  };
4026
- const done = executeRun(ctx, runId, featureId, def.run(wctx), controller, async () => {
4411
+ const done = executeRun(ctx, runId, featureId, workflowId, def.run(wctx), controller, async () => {
4027
4412
  if (talkDetached)
4028
4413
  await reattachWorktree(talkWorktree, feature.branch);
4029
4414
  });
@@ -4032,7 +4417,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4032
4417
  function cancelRun(runId) {
4033
4418
  controllers.get(runId)?.abort();
4034
4419
  }
4035
- async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup) {
4420
+ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4036
4421
  let status = "failed";
4037
4422
  let summary = "run failed";
4038
4423
  try {
@@ -4053,6 +4438,9 @@ async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup
4053
4438
  }
4054
4439
  ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
4055
4440
  releaseForSession(ctx, runId);
4441
+ if (workflowClaimsFeatureBranch(workflow2)) {
4442
+ sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
4443
+ }
4056
4444
  emit(ctx, featureId, {
4057
4445
  type: "run.finished",
4058
4446
  message: `run ${status}: ${summary}`,
@@ -4194,7 +4582,7 @@ function advance(ctx, featureId) {
4194
4582
  async function burn(ctx, featureId, opts = {}) {
4195
4583
  const feature = getFeatureRow(ctx, featureId);
4196
4584
  const running = hasActiveRun(ctx, featureId);
4197
- const tickets2 = listByFeature2(ctx, featureId);
4585
+ let tickets2 = listByFeature2(ctx, featureId);
4198
4586
  const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4199
4587
  const restarting = feature.phase === "implementation" && !running;
4200
4588
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
@@ -4212,6 +4600,8 @@ async function burn(ctx, featureId, opts = {}) {
4212
4600
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
4213
4601
  }
4214
4602
  if (restarting) {
4603
+ sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
4604
+ tickets2 = listByFeature2(ctx, featureId);
4215
4605
  const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
4216
4606
  for (const t of failed) {
4217
4607
  updateTicket(ctx, t.id, { status: "pending", error: null });
@@ -5315,7 +5705,7 @@ var init_artifacts = __esm(() => {
5315
5705
  import { existsSync as existsSync13 } from "fs";
5316
5706
  import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5317
5707
  import { fileURLToPath as fileURLToPath5 } from "url";
5318
- import { and as and5, eq as eq8 } from "drizzle-orm";
5708
+ import { and as and6, eq as eq8 } from "drizzle-orm";
5319
5709
  function ptyExitMessage(exitCode) {
5320
5710
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5321
5711
  return `terminal exited (code ${label})`;
@@ -5341,14 +5731,10 @@ function buildClaudeArgs(input) {
5341
5731
  ];
5342
5732
  }
5343
5733
  function resolveClaudeExecutable() {
5344
- return resolveExecutable("claude", { override: process.env.RUNCASTLE_CLAUDE_BIN });
5734
+ return resolveTool("claude");
5345
5735
  }
5346
5736
  function claudeSpawnTarget(claudeArgs) {
5347
- const exe = resolveClaudeExecutable();
5348
- if (/\.(cmd|bat)$/i.test(exe)) {
5349
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", exe, ...claudeArgs] };
5350
- }
5351
- return { file: exe, args: claudeArgs };
5737
+ return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5352
5738
  }
5353
5739
  function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5354
5740
  const rel = join13("packages", "skills", "packs", "runcastle");
@@ -5376,7 +5762,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5376
5762
  `)}`);
5377
5763
  }
5378
5764
  function activeRunFor(ctx, featureId) {
5379
- 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();
5380
5766
  return row ? rowToRun(row) : null;
5381
5767
  }
5382
5768
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6263,6 +6649,14 @@ var init_settings = __esm(() => {
6263
6649
  valueSchema: z9.number().int().min(0).max(3),
6264
6650
  parseEnv: (raw) => Number(raw)
6265
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
+ },
6266
6660
  {
6267
6661
  key: "setupCommand",
6268
6662
  configKey: "setupCommand",
@@ -6271,6 +6665,22 @@ var init_settings = __esm(() => {
6271
6665
  valueSchema: z9.string().min(1),
6272
6666
  parseEnv: idEnv
6273
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
+ },
6274
6684
  {
6275
6685
  key: "mainBranch",
6276
6686
  configKey: "mainBranch",
@@ -6469,10 +6879,7 @@ var init_doctor = __esm(() => {
6469
6879
  import { spawn as spawn2 } from "child_process";
6470
6880
  function createSystemExec(opts = {}) {
6471
6881
  return (command, args) => new Promise((resolve9) => {
6472
- const resolved = resolveExecutable(command);
6473
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6474
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6475
- const spawnArgs = isShim ? ["/c", resolved, ...args] : args;
6882
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
6476
6883
  let stdout = "";
6477
6884
  let stderr = "";
6478
6885
  const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
@@ -6623,12 +7030,29 @@ function prepareSandboxBuildContext() {
6623
7030
  }
6624
7031
  function createTokenVerifier(exec) {
6625
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
+ }
6626
7040
  const out = await exec("claude", ["--version"]);
6627
- if (!(out.ok && out.code === 0)) {
6628
- 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
+ };
6629
7047
  }
6630
- if (token.length < 8) {
6631
- 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
+ };
6632
7056
  }
6633
7057
  return { valid: true, detail: "token captured to ~/.runcastle/.env" };
6634
7058
  };
@@ -6680,10 +7104,7 @@ var init_setup2 = __esm(() => {
6680
7104
  const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
6681
7105
  const sessionId = newId("setup");
6682
7106
  const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
6683
- const resolved = resolveExecutable(spec.cmd);
6684
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6685
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6686
- const args = isShim ? ["/c", resolved, ...spec.args] : spec.args;
7107
+ const { file, args } = resolveSpawnTarget(spec.cmd, spec.args);
6687
7108
  ptyRegistry().create({
6688
7109
  sessionId,
6689
7110
  cmd: file,
@@ -6813,12 +7234,22 @@ import * as z12 from "zod";
6813
7234
  var ticketRouter;
6814
7235
  var init_ticket = __esm(() => {
6815
7236
  init_features();
7237
+ init_repo();
6816
7238
  init_tickets();
6817
7239
  init_ticket_burner();
6818
7240
  init_context();
6819
7241
  ticketRouter = router({
6820
7242
  retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
6821
- 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
+ }),
6822
7253
  cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
6823
7254
  });
6824
7255
  });
@@ -6859,6 +7290,7 @@ async function reconcileStaleRuns(ctx) {
6859
7290
  continue;
6860
7291
  ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
6861
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") : [];
6862
7294
  if (workflowClaimsFeatureBranch(run3.workflow)) {
6863
7295
  const feature = tryGetFeature(ctx, run3.featureId);
6864
7296
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
@@ -6875,7 +7307,8 @@ async function reconcileStaleRuns(ctx) {
6875
7307
  data: {
6876
7308
  runId: run3.id,
6877
7309
  workflow: run3.workflow,
6878
- releasedWaypointIds: released.map((w) => w.id)
7310
+ releasedWaypointIds: released.map((w) => w.id),
7311
+ sweptTicketSeqs: swept.map((t2) => t2.seq)
6879
7312
  }
6880
7313
  });
6881
7314
  reconciled.push(run3);
@@ -6893,6 +7326,7 @@ var init_reconcile_runs = __esm(() => {
6893
7326
  init_events();
6894
7327
  init_git();
6895
7328
  init_repo();
7329
+ init_tickets();
6896
7330
  init_waypoints();
6897
7331
  init_runner();
6898
7332
  });