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/bin/runcastle.js +499 -65
- package/index.js +499 -65
- package/package.json +1 -1
- package/pty-host.cjs +5 -1
- package/skills/burner/implement-ticket.md +32 -3
- package/skills/burner/resolve-conflict.md +10 -1
- package/web/assets/{index-BcWKL2hj.js → index-Cpwb7Nqn.js} +2 -2
- package/web/assets/{index-fWdYT4eC.css → index-DBVAMsFt.css} +1 -1
- package/web/index.html +2 -2
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5734
|
+
return resolveTool("claude");
|
|
5345
5735
|
}
|
|
5346
5736
|
function claudeSpawnTarget(claudeArgs) {
|
|
5347
|
-
|
|
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(
|
|
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
|
|
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 (!
|
|
6628
|
-
return {
|
|
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 (
|
|
6631
|
-
|
|
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
|
|
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 }) =>
|
|
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
|
});
|