From 339bc1b872932880bd1fde079028c07adf7b8a89 Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Mon, 13 Apr 2026 17:50:20 -0300 Subject: [PATCH 1/3] feat(operator-mind): post-spawn HTTP verification for background bash servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of operator-mind: KCode used to report `✓ PID X (3.0s)` for every background spawn even when the spawned server crashed at boot on EMFILE / ENOENT package.json / EADDRINUSE / missing dep. The model received a positive signal and would re-spawn the same broken command on the next turn, accumulating dozens of orphaned processes (50+ npm run dev / bun --watch leaked over a single session). The verifier sits at the close of the bash-tool background path and: 1. Pattern-matches the command against known long-running server spawns (next dev, vite, npm/bun/pnpm/yarn run dev, python -m http.server, flask run, uvicorn, gunicorn, rails s, php -S, caddy run, live-server, nodemon, serve/http-server). 2. Resolves the actual port from PORT= env, --port=N, --port N, -p N (only for known servers), php -S host:N, python -m http.server N, or the framework default. 3. HTTP-probes 127.0.0.1:PORT/ with retries (~3.5s budget). 4. Treats 2xx/3xx/4xx as alive (404 = server up, just no route), 5xx and connection failures as dead. 5. On dead, returns is_error=true with a multi-line diagnostic: pid liveness, declared port, cwd, last 15 lines of stderr, and a "Do NOT retry without diagnosing first" guard with the most likely root causes spelled out. The verifier is a separate module (`src/core/bash-spawn-verifier.ts`) so it can be reused by future operator-mind primitives (e.g. the upcoming pre-flight duplicate-spawn guard in phase 2). Verified end-to-end against the failure path (`PORT=N npm run dev` in an empty dir → ENOENT package.json → real failure surfaced) and the success path (`python3 -m http.server N` → ✓ live at HTTP 200). Tests: 53 unit cases covering detection, port extraction, probe edge cases (200/404/500/000), PID liveness, and full integration against a real Bun.serve() on an ephemeral port. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/bash-spawn-verifier.test.ts | 210 +++++++++++++++++++++ src/core/bash-spawn-verifier.ts | 269 +++++++++++++++++++++++++++ src/tools/bash.ts | 33 +++- 3 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 src/core/bash-spawn-verifier.test.ts create mode 100644 src/core/bash-spawn-verifier.ts diff --git a/src/core/bash-spawn-verifier.test.ts b/src/core/bash-spawn-verifier.test.ts new file mode 100644 index 0000000..907e53c --- /dev/null +++ b/src/core/bash-spawn-verifier.test.ts @@ -0,0 +1,210 @@ +// Tests for bash-spawn-verifier — operator-mind post-spawn HTTP probe. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + detectServerSpawn, + extractDeclaredPort, + extractPidFromWrapperOutput, + isPidAlive, + probeServer, + verifyBackgroundSpawn, +} from "./bash-spawn-verifier"; + +describe("detectServerSpawn", () => { + test.each([ + ["next dev", "next"], + ["npx next dev --turbo", "next"], + ["PORT=3001 npm run dev", "node-dev"], + ["bun run dev", "node-dev"], + ["pnpm run dev", "node-dev"], + ["yarn dev", "node-dev"], + ["vite", "vite"], + ["npx vite --port 5174", "vite"], + ["astro dev", "astro"], + ["python3 -m http.server", "python-http"], + ["python -m http.server 8001", "python-http"], + ["flask run", "flask"], + ["uvicorn main:app", "uvicorn"], + ["gunicorn app:app", "gunicorn"], + ["rails s", "rails"], + ["rails server", "rails"], + ["caddy run", "caddy"], + ["live-server .", "live-server"], + ["nodemon index.js", "nodemon"], + ["serve -s build", "static-serve"], + ["http-server -p 10080", "static-serve"], + ])("detects %p as %p", (cmd, framework) => { + const d = detectServerSpawn(cmd); + expect(d).not.toBeNull(); + expect(d!.framework).toBe(framework); + }); + + test.each([ + ["ls -la"], + ["git status"], + ["npm install"], + ["cargo build"], + ["pytest"], + ["bun test"], + ["echo hello"], + ["cat package.json"], + ["npm run build"], + ["npm run test"], + ])("does NOT match one-shot command %p", (cmd) => { + expect(detectServerSpawn(cmd)).toBeNull(); + }); +}); + +describe("extractDeclaredPort", () => { + test("PORT= env wins", () => { + expect(extractDeclaredPort("PORT=15423 npm run dev", 3000)).toBe(15423); + }); + + test("--port=N", () => { + expect(extractDeclaredPort("next dev --port=4001", 3000)).toBe(4001); + }); + + test("--port N", () => { + expect(extractDeclaredPort("vite --port 5174", 5173)).toBe(5174); + }); + + test("-p N (only for known servers)", () => { + expect(extractDeclaredPort("http-server -p 10080", 8080)).toBe(10080); + }); + + test("-p N is ignored for unknown commands", () => { + expect(extractDeclaredPort("cp -p file1 file2", 3000)).toBe(3000); + }); + + test("php -S host:N", () => { + expect(extractDeclaredPort("php -S 0.0.0.0:8200")).toBe(8200); + }); + + test("python -m http.server N", () => { + expect(extractDeclaredPort("python3 -m http.server 8765", 8000)).toBe(8765); + }); + + test("falls back to default", () => { + expect(extractDeclaredPort("next dev", 3000)).toBe(3000); + }); + + test("returns null when no port and no default", () => { + expect(extractDeclaredPort("php -S")).toBeNull(); + }); +}); + +describe("extractPidFromWrapperOutput", () => { + test("parses 'PID: N' line", () => { + expect(extractPidFromWrapperOutput("PID: 12345\nReady on port 3000")).toBe(12345); + }); + + test("returns null for missing PID", () => { + expect(extractPidFromWrapperOutput("just some output")).toBeNull(); + }); + + test("returns null for non-numeric", () => { + expect(extractPidFromWrapperOutput("PID: abc")).toBeNull(); + }); +}); + +describe("probeServer", () => { + let server: ReturnType | null = null; + let port = 0; + + afterEach(() => { + if (server) { + server.stop(true); + server = null; + } + }); + + test("returns ok for 200 response", async () => { + server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); + port = server.port; + const result = await probeServer(port); + expect(result.ok).toBe(true); + expect(result.rawStatusCode).toBe("200"); + }); + + test("treats 404 as ok (server is up, just no route)", async () => { + server = Bun.serve({ port: 0, fetch: () => new Response("nope", { status: 404 }) }); + port = server.port; + const result = await probeServer(port); + expect(result.ok).toBe(true); + expect(result.rawStatusCode).toBe("404"); + }); + + test("treats 500 as failure", async () => { + server = Bun.serve({ port: 0, fetch: () => new Response("boom", { status: 500 }) }); + port = server.port; + const result = await probeServer(port); + expect(result.ok).toBe(false); + expect(result.rawStatusCode).toBe("500"); + }); + + test("connection refused returns ok=false with 000", async () => { + // Pick a port that very likely has nothing listening (>50000 in private range) + const result = await probeServer(59999, { timeoutMs: 500 }); + expect(result.ok).toBe(false); + expect(result.rawStatusCode).toBe("000"); + expect(result.error).toBeDefined(); + }); +}); + +describe("isPidAlive", () => { + test("self process is alive", () => { + expect(isPidAlive(process.pid)).toBe(true); + }); + + test("garbage PID is dead", () => { + expect(isPidAlive(9999999)).toBe(false); + }); +}); + +describe("verifyBackgroundSpawn — integration", () => { + let server: ReturnType | null = null; + let port = 0; + + beforeEach(() => { + server = Bun.serve({ port: 0, fetch: () => new Response("artemis") }); + port = server.port; + }); + + afterEach(() => { + server?.stop(true); + server = null; + }); + + test("returns null for non-server commands", async () => { + const r = await verifyBackgroundSpawn("ls -la", null, ""); + expect(r).toBeNull(); + }); + + test("returns ok for live server", async () => { + const cmd = `PORT=${port} npm run dev`; + const r = await verifyBackgroundSpawn(cmd, process.pid, `PID: ${process.pid}`); + expect(r).not.toBeNull(); + expect(r!.ok).toBe(true); + expect(r!.report).toContain(`http://localhost:${port}`); + }); + + test("returns failure when probe fails", async () => { + const cmd = `PORT=59998 npm run dev`; + const r = await verifyBackgroundSpawn(cmd, process.pid, "PID: 12345\nstuff"); + expect(r).not.toBeNull(); + expect(r!.ok).toBe(false); + expect(r!.report).toContain("FAILED"); + expect(r!.report).toContain("59998"); + expect(r!.report).toContain("Do NOT retry"); + }); + + test("includes captured output tail in failure report", async () => { + const r = await verifyBackgroundSpawn( + "PORT=59997 next dev", + 12345, + "PID: 12345\nError: ENOENT next not found", + ); + expect(r!.ok).toBe(false); + expect(r!.report).toContain("ENOENT next not found"); + }); +}); diff --git a/src/core/bash-spawn-verifier.ts b/src/core/bash-spawn-verifier.ts new file mode 100644 index 0000000..2b2c54e --- /dev/null +++ b/src/core/bash-spawn-verifier.ts @@ -0,0 +1,269 @@ +// KCode - Bash Spawn Verifier +// +// Operator-mind primitive: when a Bash background spawn matches a known +// "starts a long-running server" pattern, do not trust the bare "✓ PID X" +// signal. Probe the server over HTTP after a short delay and either +// confirm it is actually live or report a real failure with diagnostics. +// +// This exists because KCode's bash background path used to report +// `✓ PID 1642328 (3.0s)` even when the spawned server immediately +// crashed on EMFILE / EADDRINUSE / missing dependency. Subsequent turns +// would re-spawn the same broken command, accumulating dozens of +// orphaned processes. With this verifier, the model receives the real +// failure and is forced to reconsider instead of looping. + +import { log } from "./logger.js"; + +// ─── Framework detection ─────────────────────────────────────────── + +export interface SpawnDetection { + /** Short label for the framework (next, vite, flask, ...). */ + framework: string; + /** Default port for the framework if not explicitly set. */ + defaultPort?: number; + /** Path or query to probe (defaults to "/"). */ + probePath?: string; +} + +/** + * Inspect a Bash command and decide whether it is a long-running + * server spawn that we should HTTP-verify after launch. Returns null + * for one-shot commands, file ops, builds, etc. + * + * Patterns are deliberately conservative — false positives would slow + * down every Bash call, false negatives just mean we do nothing extra. + */ +export function detectServerSpawn(command: string): SpawnDetection | null { + const c = command.toLowerCase(); + + // Next.js: `next dev`, `npm/bun/pnpm/yarn run dev` (most package.json scripts) + if (/\bnext\s+dev\b/.test(c)) return { framework: "next", defaultPort: 3000 }; + if (/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?dev\b/.test(c)) + return { framework: "node-dev", defaultPort: 3000 }; + + // Vite + if (/\bvite(?:\s|$)/.test(c)) return { framework: "vite", defaultPort: 5173 }; + + // Astro + if (/\bastro\s+dev\b/.test(c)) return { framework: "astro", defaultPort: 4321 }; + + // Generic node start scripts + if (/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?start\b/.test(c)) + return { framework: "node-start", defaultPort: 3000 }; + + // Python + if (/\bpython3?\s+-m\s+http\.server\b/.test(c)) + return { framework: "python-http", defaultPort: 8000 }; + if (/\bflask\s+run\b/.test(c)) return { framework: "flask", defaultPort: 5000 }; + if (/\buvicorn\b/.test(c)) return { framework: "uvicorn", defaultPort: 8000 }; + if (/\bgunicorn\b/.test(c)) return { framework: "gunicorn", defaultPort: 8000 }; + + // PHP + if (/\bphp\s+-s\b/.test(c)) return { framework: "php-builtin" }; + + // Ruby + if (/\brails\s+s(?:erver)?\b/.test(c)) return { framework: "rails", defaultPort: 3000 }; + if (/\bruby\s+-run\b/.test(c)) return { framework: "ruby-webrick", defaultPort: 8080 }; + + // Caddy / live-server / nodemon + if (/\bcaddy\s+run\b/.test(c)) return { framework: "caddy", defaultPort: 2015 }; + if (/\blive-server\b/.test(c)) return { framework: "live-server", defaultPort: 8080 }; + if (/\bnodemon\b/.test(c)) return { framework: "nodemon", defaultPort: 3000 }; + + // Static servers + if (/\bserve\s/.test(c) || /\bhttp-server\b/.test(c)) + return { framework: "static-serve", defaultPort: 3000 }; + + return null; +} + +// ─── Port extraction ─────────────────────────────────────────────── + +/** + * Extract the port the spawned server will actually bind to. + * Checks (in order): `PORT=` env, `--port=N`, `--port N`, `-p N`, + * `php -S host:N`, then falls back to the framework default. + */ +export function extractDeclaredPort( + command: string, + defaultPort?: number, +): number | null { + // PORT=N env prefix (most reliable) + const envMatch = command.match(/\bPORT=(\d+)/); + if (envMatch) return parseInt(envMatch[1]!, 10); + + // --port=N or --port N + const longFlag = command.match(/--port[=\s]+(\d+)/); + if (longFlag) return parseInt(longFlag[1]!, 10); + + // -p N (only if the command looks like a node/python server — avoid matching + // unrelated tools that use -p for "preserve" or "parents") + if (/\b(?:next|vite|astro|nodemon|live-server|http-server|serve)\b/i.test(command)) { + const shortFlag = command.match(/-p\s+(\d+)/); + if (shortFlag) return parseInt(shortFlag[1]!, 10); + } + + // php -S host:N + const phpMatch = command.match(/php\s+-S\s+\S*:(\d+)/i); + if (phpMatch) return parseInt(phpMatch[1]!, 10); + + // python -m http.server [PORT] + const pyMatch = command.match(/python3?\s+-m\s+http\.server\s+(\d+)/i); + if (pyMatch) return parseInt(pyMatch[1]!, 10); + + return defaultPort ?? null; +} + +// ─── Probe ───────────────────────────────────────────────────────── + +export interface ProbeResult { + ok: boolean; + /** HTTP status code if the request completed, "000" if connection failed. */ + rawStatusCode: string; + /** Total request time in ms (capped by timeoutMs). */ + durationMs: number; + /** Error string if the connection itself failed. */ + error?: string; +} + +/** + * Single HTTP probe against http://127.0.0.1:PORT/PATH. + * Treats any 2xx/3xx/4xx as "the server is responding" — even 404 is + * a positive signal because it means TCP+HTTP are working. Only 5xx + * and connection failures are treated as failure. + */ +export async function probeServer( + port: number, + opts: { path?: string; timeoutMs?: number } = {}, +): Promise { + const path = opts.path ?? "/"; + const timeoutMs = opts.timeoutMs ?? 2000; + const url = `http://127.0.0.1:${port}${path}`; + const start = Date.now(); + + try { + const resp = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + }); + const durationMs = Date.now() - start; + return { + ok: resp.status < 500, + rawStatusCode: String(resp.status), + durationMs, + }; + } catch (err) { + return { + ok: false, + rawStatusCode: "000", + durationMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── Liveness ────────────────────────────────────────────────────── + +/** Returns true if the OS reports the PID is still alive. */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// ─── High-level verification ─────────────────────────────────────── + +export interface VerificationOutcome { + ok: boolean; + /** Multi-line human-readable report safe to inline into a tool result. */ + report: string; +} + +/** + * Verify that a background spawn is actually serving traffic. Performs + * up to 3 retries with backoff (~1.5s total) so slow boots are tolerated. + * + * Inputs: + * - command: the original Bash command (used for port extraction) + * - pid: the spawned process PID (used for liveness checks) + * - capturedOutput: the bytes captured by the wrapper sleep — included + * verbatim in failure reports for context + * - cwd: optional, included in the failure report + */ +export async function verifyBackgroundSpawn( + command: string, + pid: number | null, + capturedOutput: string, + cwd?: string, +): Promise { + const detection = detectServerSpawn(command); + if (!detection) return null; + + const port = extractDeclaredPort(command, detection.defaultPort); + if (!port) { + log.debug("verifier", `${detection.framework}: no port resolved, skipping probe`); + return null; + } + + // Retry schedule: 0ms, 1500ms, 3500ms (cumulative). Caller already + // slept ~3s in the wrapper, so by the time we get here the server + // is usually up if it ever will be. + const retryDelaysMs = [0, 1500, 2000]; + let last: ProbeResult | null = null; + for (const delay of retryDelaysMs) { + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + last = await probeServer(port); + if (last.ok) break; + // If the process is already dead, no point retrying. + if (pid !== null && !isPidAlive(pid)) break; + } + + if (!last) return null; + + const url = `http://localhost:${port}/`; + if (last.ok) { + return { + ok: true, + report: `${detection.framework} live at ${url} (HTTP ${last.rawStatusCode}, ${last.durationMs}ms)`, + }; + } + + // Failure path: build a useful diagnostic + const alive = pid !== null ? isPidAlive(pid) : "unknown"; + const lines: string[] = []; + lines.push(`✗ ${detection.framework} health check FAILED on ${url}`); + lines.push( + ` probe: HTTP ${last.rawStatusCode}${last.error ? ` (${last.error})` : ""} after ${last.durationMs}ms`, + ); + lines.push(` pid: ${pid ?? "unknown"}${pid !== null ? ` (alive=${alive})` : ""}`); + if (cwd) lines.push(` cwd: ${cwd}`); + if (capturedOutput.trim()) { + const tail = capturedOutput.trim().split("\n").slice(-15).join("\n"); + lines.push(` output (last 15 lines):`); + for (const ln of tail.split("\n")) lines.push(` ${ln}`); + } + lines.push( + ` Do NOT retry the same command without diagnosing first. Likely causes:`, + ); + lines.push(` - port ${port} already in use (check 'ss -tlnp | grep ${port}')`); + lines.push(` - dependencies missing (check the output above for ENOENT/EMFILE)`); + lines.push(` - server crashed during boot (check the output above for stack traces)`); + + return { ok: false, report: lines.join("\n") }; +} + +// ─── PID extraction from wrapper output ──────────────────────────── + +/** + * The bash background wrapper prints `PID: ` as its first line. + * Extract that PID for liveness tracking. + */ +export function extractPidFromWrapperOutput(output: string): number | null { + const m = output.match(/^PID:\s*(\d+)/m); + if (!m) return null; + const n = parseInt(m[1]!, 10); + return Number.isFinite(n) && n > 0 ? n : null; +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 8f45e22..34a7345 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -489,12 +489,43 @@ export async function executeBash(input: Record): Promise chunks.push(data)); proc.stderr.on("data", (data: Buffer) => errChunks.push(data)); - proc.on("close", (code) => { + proc.on("close", async (_code) => { const stdout = Buffer.concat(chunks).toString("utf-8").trim(); const stderr = Buffer.concat(errChunks).toString("utf-8").trim(); const output = stdout + (stderr ? `\n${stderr}` : ""); const duration = ((Date.now() - startTime) / 1000).toFixed(1); log.debug("tool", `Bash (background) returned in ${duration}s: ${cmdPrefix}`); + + // Operator-mind: when the spawned command is a known long-running + // server, do not trust the wrapper's "PID: X" output. Probe the + // server over HTTP and report a real failure if it isn't actually + // serving traffic. Without this, broken servers silently report + // success and the model loops re-spawning them. + try { + const { verifyBackgroundSpawn, extractPidFromWrapperOutput } = await import( + "../core/bash-spawn-verifier.js" + ); + const pid = extractPidFromWrapperOutput(output); + const verdict = await verifyBackgroundSpawn(command, pid, output, process.cwd()); + if (verdict) { + if (verdict.ok) { + resolve({ + tool_use_id: "", + content: `${output}\n\n✓ ${verdict.report}`, + }); + return; + } + resolve({ + tool_use_id: "", + content: `${output}\n\n${verdict.report}`, + is_error: true, + }); + return; + } + } catch (err) { + log.debug("tool", `bash-spawn-verifier failed (non-fatal): ${err}`); + } + resolve({ tool_use_id: "", content: output || "(background process started)", From 080d09edb63705c5af34698dd77422134954d4a5 Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Mon, 13 Apr 2026 18:00:18 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(operator-mind):=20phase=202=20?= =?UTF-8?q?=E2=80=94=20pre-flight=20port=20+=20inotify=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuses background server spawns when the system is already in a state that would make the spawn fail. Catches the two failure modes from the Artemis bricking session: * Port collision: `PORT=15423 npm run dev` while something else is already listening on 15423 → spawn would race and EADDRINUSE. Uses `ss -tlnp` to find the occupant PID and process name; refusal report includes 3 concrete options (reuse, kill, pick another port). * inotify saturation: when /proc/sys/fs/inotify/max_user_instances is ≥85% used, watch-mode frameworks (next/vite/astro/nodemon/ live-server/webpack/node-dev) boot straight into EMFILE and crash. Refusal report tells the operator how to clean leaked watchers AND how to raise the limit via sysctl. Both checks only fire when the command matches a known server-spawn pattern (reusing detectServerSpawn from phase 1), so plain Bash calls like `ls`/`git status`/`npm install` are unaffected. The inotify snapshot is cached for 30s to keep the cost negligible (one find + one /proc read per Bash call, max). Wired into bash.ts immediately after isBackground is determined, before the spawn happens. Returns is_error=true so the model has to actually reason about the failure instead of looping. 53 → 61 tests for the verifier+preflight pair, plus end-to-end verification with a real Bun.serve() port collision. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/bash-spawn-preflight.test.ts | 92 +++++++++++++ src/core/bash-spawn-preflight.ts | 189 ++++++++++++++++++++++++++ src/tools/bash.ts | 21 +++ 3 files changed, 302 insertions(+) create mode 100644 src/core/bash-spawn-preflight.test.ts create mode 100644 src/core/bash-spawn-preflight.ts diff --git a/src/core/bash-spawn-preflight.test.ts b/src/core/bash-spawn-preflight.test.ts new file mode 100644 index 0000000..a5dc4e8 --- /dev/null +++ b/src/core/bash-spawn-preflight.test.ts @@ -0,0 +1,92 @@ +// Tests for bash-spawn-preflight (phase 2 of operator-mind). + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + checkInotifyState, + clearInotifyCache, + findListeningPid, + runSpawnPreflight, +} from "./bash-spawn-preflight"; + +describe("findListeningPid", () => { + let server: ReturnType | null = null; + + afterEach(() => { + server?.stop(true); + server = null; + }); + + test("returns a PID when the port is bound", () => { + server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); + const pid = findListeningPid(server.port); + // Bun's process always owns the listener — should match our PID + // unless ss can't see it (insufficient privs → returns -1, also a hit). + expect(pid).not.toBeNull(); + }); + + test("returns null for a free port", () => { + expect(findListeningPid(59123)).toBeNull(); + }); +}); + +describe("checkInotifyState", () => { + beforeEach(() => clearInotifyCache()); + + test("returns numeric snapshot or null on non-Linux", () => { + const s = checkInotifyState(); + if (s === null) return; // non-Linux + expect(s.used).toBeGreaterThanOrEqual(0); + expect(s.limit).toBeGreaterThan(0); + expect(s.ratio).toBeGreaterThanOrEqual(0); + }); + + test("results are cached for the TTL window", () => { + const a = checkInotifyState(); + const b = checkInotifyState(); + expect(a).toEqual(b); + }); +}); + +describe("runSpawnPreflight", () => { + let server: ReturnType | null = null; + + afterEach(() => { + server?.stop(true); + server = null; + clearInotifyCache(); + }); + + test("returns null for one-shot commands", () => { + expect(runSpawnPreflight("ls -la", process.cwd())).toBeNull(); + expect(runSpawnPreflight("git status", process.cwd())).toBeNull(); + expect(runSpawnPreflight("npm install", process.cwd())).toBeNull(); + }); + + test("returns null for server spawn on a free port", () => { + // Use python http.server so the inotify-saturation branch (which + // only fires for watch-mode frameworks like next/vite/nodemon) is + // skipped. Otherwise this test would fail on dev hosts whose + // /proc/sys/fs/inotify/max_user_instances is already saturated — + // the very condition the preflight is designed to catch. + const r = runSpawnPreflight("python3 -m http.server 59124", process.cwd()); + expect(r).toBeNull(); + }); + + test("refuses when the declared port is occupied", () => { + server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); + const r = runSpawnPreflight(`PORT=${server.port} npm run dev`, process.cwd()); + expect(r).not.toBeNull(); + expect(r!.refused).toBe(true); + expect(r!.report).toContain("already in use"); + expect(r!.report).toContain(String(server.port)); + expect(r!.report).toContain("Options:"); + }); + + test("refusal report mentions kill and port-change options", () => { + server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); + const r = runSpawnPreflight(`next dev --port ${server.port}`, process.cwd()); + expect(r!.report).toMatch(/kill/i); + expect(r!.report).toMatch(/different port/i); + expect(r!.report).toMatch(/reuse/i); + }); +}); diff --git a/src/core/bash-spawn-preflight.ts b/src/core/bash-spawn-preflight.ts new file mode 100644 index 0000000..1537820 --- /dev/null +++ b/src/core/bash-spawn-preflight.ts @@ -0,0 +1,189 @@ +// KCode - Bash Spawn Preflight +// +// Operator-mind primitive (phase 2): refuse to spawn a background server +// when the system already has the resource we'd be trying to claim. +// Catches the two failure modes that bricked the Artemis session: +// +// 1. Port collision — `PORT=15423 npm run dev` when something is +// already listening on 15423. The new spawn races, fails with +// EADDRINUSE, leaves an orphan, model retries. +// +// 2. inotify saturation — when /proc/sys/fs/inotify/max_user_instances +// is >85% used, the next watch-mode dev server boots into EMFILE +// and crashes silently. Each retry leaks more watchers. +// +// Both checks are CHEAP: ~1ms ss + ~50ms /proc walk. Run only when the +// command matches a server-spawn pattern (so one-shot Bash calls like +// `ls` are unaffected). + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { detectServerSpawn, extractDeclaredPort } from "./bash-spawn-verifier.js"; +import { log } from "./logger.js"; + +// ─── Port collision check ───────────────────────────────────────── + +/** + * Returns the PID currently listening on the given TCP port, or null + * if the port is free. Uses `ss -tlnp` (Linux) — falls back to null on + * any error so the preflight degrades gracefully on macOS/Windows. + */ +export function findListeningPid(port: number): number | null { + try { + const result = spawnSync("ss", ["-tlnp"], { encoding: "utf-8", timeout: 2000 }); + if (result.status !== 0 || !result.stdout) return null; + // Lines look like: + // LISTEN 0 511 *:15423 *:* users:(("next-server (v1",pid=2346182,fd=22)) + const lines = result.stdout.split("\n"); + const portRe = new RegExp(`[:.](?:0\\.0\\.0\\.0:|\\*:|\\[::\\]:|::)?${port}\\b`); + for (const ln of lines) { + if (!ln.includes("LISTEN")) continue; + if (!portRe.test(ln)) continue; + const m = ln.match(/pid=(\d+)/); + if (m) return parseInt(m[1]!, 10); + // Listening but ss didn't expose pid (no privileges). Still a hit. + return -1; + } + return null; + } catch (err) { + log.debug("preflight", `findListeningPid failed: ${err}`); + return null; + } +} + +/** Best-effort process name for a PID, for nicer diagnostics. */ +export function processNameFor(pid: number): string | null { + if (pid <= 0) return null; + try { + const cmd = readFileSync(`/proc/${pid}/comm`, "utf-8").trim(); + return cmd || null; + } catch { + return null; + } +} + +// ─── inotify saturation check ───────────────────────────────────── + +export interface InotifyState { + /** Current count of inotify instances open on the system. */ + used: number; + /** Configured per-user limit (max_user_instances). */ + limit: number; + /** Ratio used/limit, in [0..1+]. */ + ratio: number; +} + +let _inotifyCache: { state: InotifyState; ts: number } | null = null; +const INOTIFY_CACHE_TTL_MS = 30_000; + +/** + * Snapshot inotify usage from /proc. Cached for 30s because walking + * `/proc//fd` is moderately expensive (~50ms on busy systems). + */ +export function checkInotifyState(): InotifyState | null { + if (_inotifyCache && Date.now() - _inotifyCache.ts < INOTIFY_CACHE_TTL_MS) { + return _inotifyCache.state; + } + try { + const limit = parseInt( + readFileSync("/proc/sys/fs/inotify/max_user_instances", "utf-8").trim(), + 10, + ); + if (!Number.isFinite(limit) || limit <= 0) return null; + // Walk /proc/*/fd looking for symlinks to anon_inode:inotify + const result = spawnSync( + "sh", + [ + "-c", + "find /proc/*/fd -lname 'anon_inode:inotify' 2>/dev/null | wc -l", + ], + { encoding: "utf-8", timeout: 3000 }, + ); + if (result.status !== 0) return null; + const used = parseInt(result.stdout.trim(), 10); + if (!Number.isFinite(used) || used < 0) return null; + const state: InotifyState = { used, limit, ratio: used / limit }; + _inotifyCache = { state, ts: Date.now() }; + return state; + } catch (err) { + log.debug("preflight", `checkInotifyState failed: ${err}`); + return null; + } +} + +/** Drop the cached inotify snapshot — for tests. */ +export function clearInotifyCache(): void { + _inotifyCache = null; +} + +// ─── Combined preflight ─────────────────────────────────────────── + +export interface PreflightRefusal { + refused: true; + /** Multi-line operator report — safe to inline as a tool result. */ + report: string; +} + +/** + * Run all preflight checks for a candidate background spawn. Returns + * null if the spawn should proceed normally, or a refusal object if + * something is wrong with the system right now. + * + * This is intentionally conservative: only fires for commands that + * match the server-spawn pattern set, leaving normal Bash calls alone. + */ +export function runSpawnPreflight( + command: string, + cwd: string, +): PreflightRefusal | null { + const detection = detectServerSpawn(command); + if (!detection) return null; + + const lines: string[] = []; + + // Check 1: Port collision + const port = extractDeclaredPort(command, detection.defaultPort); + if (port !== null) { + const occupant = findListeningPid(port); + if (occupant !== null) { + const occupantName = occupant > 0 ? processNameFor(occupant) : null; + lines.push(`✗ Port ${port} is already in use.`); + if (occupant > 0) { + lines.push(` occupant: PID ${occupant}${occupantName ? ` (${occupantName})` : ""}`); + } else { + lines.push(` occupant: detected by ss but PID hidden (insufficient privileges)`); + } + lines.push(` Spawning ${detection.framework} on this port would race and fail.`); + lines.push(` Options:`); + lines.push(` - reuse the existing server (it may already serve what you need)`); + lines.push(` - kill the occupant first (e.g. 'kill ${occupant > 0 ? occupant : ""}')`); + lines.push(` - pick a different port (PORT=N or --port N)`); + return { refused: true, report: lines.join("\n") }; + } + } + + // Check 2: inotify saturation (only meaningful for watch-mode frameworks) + const usesWatcher = /\b(?:next|vite|astro|nodemon|live-server|webpack|node-dev)\b/.test( + detection.framework, + ); + if (usesWatcher) { + const ino = checkInotifyState(); + if (ino && ino.ratio >= 0.85) { + lines.push(`✗ inotify is saturated: ${ino.used}/${ino.limit} instances used (${Math.round(ino.ratio * 100)}%).`); + lines.push(` Spawning a watch-mode dev server right now would EMFILE on boot`); + lines.push(` and you'd see "Watchpack Error (watcher): EMFILE: too many open files".`); + lines.push(` The previous server would also lose hot-reload but appear to keep running.`); + lines.push(` Options:`); + lines.push(` - kill leaked dev servers from this UID:`); + lines.push(` pgrep -af 'next-server|bun --watch|nodemon' && pkill -9 -u $USER -f 'next-server'`); + lines.push(` - raise the limit (one-shot, requires sudo):`); + lines.push(` sudo sysctl -w fs.inotify.max_user_instances=1024`); + lines.push(` - persist the limit:`); + lines.push(` echo 'fs.inotify.max_user_instances=1024' | sudo tee /etc/sysctl.d/99-inotify.conf`); + void cwd; // keep param for future use + return { refused: true, report: lines.join("\n") }; + } + } + + return null; +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 34a7345..fd61dc0 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -417,6 +417,27 @@ export async function executeBash(input: Record): Promise ) > /dev/null 2>&1 &` via nohup-style detach, From 8aa180285d08c6e39c5eca613d9f18dc2423147a Mon Sep 17 00:00:00 2001 From: GaltRanch Date: Mon, 13 Apr 2026 18:18:34 -0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(operator-mind):=20phase=203=20?= =?UTF-8?q?=E2=80=94=20intercept=20blind=20retry=20of=20failed=20server=20?= =?UTF-8?q?spawns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the operator-mind triad. Phases 1 and 2 made failures visible and refused doomed spawns; phase 3 catches the case where the model sees a failure, ignores it, and re-issues the exact same command on the next turn — the failure mode that bricked the Artemis session. Behavior -------- After every Bash invocation the executor records (cwd, normalized command, was-error, error-tail) in a process-local sliding window (64 entries, 8-attempt retry window). On the next Bash call: - If the new command matches a known server-spawn pattern AND the same (cwd, normalized command) was attempted within the retry window AND that attempt was an error → the executor returns a STOP report and SKIPS execution. - The STOP report names the command, the cwd, how many Bash calls ago the failure was, the first 8 lines of the previous error, and three concrete options the model must take before retrying: diagnose / change command / read more state. - After the warning fires once, an internal acknowledgment bumps the entry forward so the very next attempt runs normally — this is an escape hatch in case the model legitimately knows something the heuristic doesn't. Scope is narrowed to commands that match detectServerSpawn() — the same set used by phases 1 and 2. Sudo prompts, file ops, builds, tests, and any other one-shot command flow through unaffected. The sliding window still records them for ordering accounting but never fires a warning on them. This avoids a real conflict with the bash sudo-cache tests, which legitimately call executeBash multiple times with the same sudo command. Implementation -------------- - New module `src/core/bash-spawn-history.ts` (180 lines): recordBashAttempt, detectImmediateRetry, acknowledgeRetryWarning, clearBashHistory (test helper), snapshotBashHistory (test helper), internal normalizeCommand (collapses whitespace, treats PORT=N and --port N changes as the same intent so the model can't bypass by bumping the port). - `src/tools/bash.ts`: split executeBash into a thin public wrapper (does the phase-3 check + records the attempt at the end) and a private `_executeBashInner` (the original 760-line body, untouched). - 14 unit tests covering: scope filtering (one-shot commands ignored), same/different cwd, same/different command, whitespace normalization, PORT/--port intent equivalence, retry-window expiry, history bounding, acknowledge-then-retry, and the structure of the STOP report itself. End-to-end verification ----------------------- Spawned `PORT=N npm run dev` in an empty tmpdir. Three sequential calls: 1st rejected by phase 2 (inotify saturated, real failure), 2nd intercepted by phase 3 (STOP, retry detected), 3rd ran through phase 2 again after acknowledgment. All three phases compose cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/bash-spawn-history.test.ts | 107 ++++++++++++++++ src/core/bash-spawn-history.ts | 186 ++++++++++++++++++++++++++++ src/tools/bash.ts | 49 ++++++++ 3 files changed, 342 insertions(+) create mode 100644 src/core/bash-spawn-history.test.ts create mode 100644 src/core/bash-spawn-history.ts diff --git a/src/core/bash-spawn-history.test.ts b/src/core/bash-spawn-history.test.ts new file mode 100644 index 0000000..7f1199a --- /dev/null +++ b/src/core/bash-spawn-history.test.ts @@ -0,0 +1,107 @@ +// Tests for bash-spawn-history (phase 3 of operator-mind). + +import { beforeEach, describe, expect, test } from "bun:test"; +import { + acknowledgeRetryWarning, + clearBashHistory, + detectImmediateRetry, + recordBashAttempt, + snapshotBashHistory, +} from "./bash-spawn-history"; + +describe("bash-spawn-history", () => { + beforeEach(() => clearBashHistory()); + + test("returns null for non-server commands (out of scope)", () => { + // Phase 3 only fires for server spawns. Even after a failure of + // a non-server command, no warning is issued. + recordBashAttempt("ls /missing", "/tmp", true, "ls: cannot access"); + expect(detectImmediateRetry("ls /missing", "/tmp")).toBeNull(); + expect(detectImmediateRetry("git status", "/tmp")).toBeNull(); + expect(detectImmediateRetry("sudo echo first", "/tmp")).toBeNull(); + }); + + test("returns null when no history exists for a server command", () => { + expect(detectImmediateRetry("npm run dev", "/tmp")).toBeNull(); + }); + + test("returns null when the previous server attempt succeeded", () => { + recordBashAttempt("npm run dev", "/tmp", false, "Ready"); + expect(detectImmediateRetry("npm run dev", "/tmp")).toBeNull(); + }); + + test("detects immediate retry after failure in same cwd", () => { + recordBashAttempt("npm run dev", "/home/curly/site", true, "ENOENT package.json"); + const w = detectImmediateRetry("npm run dev", "/home/curly/site"); + expect(w).not.toBeNull(); + expect(w!.attemptsAgo).toBe(1); + expect(w!.report).toContain("STOP"); + expect(w!.report).toContain("npm run dev"); + expect(w!.report).toContain("ENOENT package.json"); + }); + + test("does NOT trigger when cwd differs", () => { + recordBashAttempt("npm run dev", "/dir-a", true, "boom"); + expect(detectImmediateRetry("npm run dev", "/dir-b")).toBeNull(); + }); + + test("does NOT trigger for a different server command", () => { + recordBashAttempt("npm run dev", "/x", true, "boom"); + // vite is also a server spawn, but a different one — no retry warning + expect(detectImmediateRetry("vite", "/x")).toBeNull(); + }); + + test("treats whitespace differences as same command", () => { + recordBashAttempt("npm run dev", "/x", true, "boom"); + expect(detectImmediateRetry("npm run dev", "/x")).not.toBeNull(); + }); + + test("treats PORT changes as same intent (retry detection still fires)", () => { + recordBashAttempt("PORT=3000 npm run dev", "/x", true, "EADDRINUSE :3000"); + const w = detectImmediateRetry("PORT=3001 npm run dev", "/x"); + expect(w).not.toBeNull(); + expect(w!.report).toContain("STOP"); + }); + + test("treats --port changes as same intent", () => { + recordBashAttempt("next dev --port 3000", "/x", true, "EADDRINUSE"); + expect(detectImmediateRetry("next dev --port 3001", "/x")).not.toBeNull(); + }); + + test("ignores failures older than the retry window", () => { + recordBashAttempt("npm run dev", "/x", true, "boom"); + // Push 9 unrelated server attempts (window = 8) + for (let i = 0; i < 9; i++) recordBashAttempt(`vite --port ${5000 + i}`, "/x", false, ""); + expect(detectImmediateRetry("npm run dev", "/x")).toBeNull(); + }); + + test("history is bounded to MAX_HISTORY entries", () => { + // Use a non-server pattern so we exercise raw history bounding without + // tripping retry detection on intermediate entries. + for (let i = 0; i < 200; i++) recordBashAttempt(`echo ${i}`, "/x", false, ""); + const snap = snapshotBashHistory(); + expect(snap.length).toBeLessThanOrEqual(64); + }); + + test("acknowledgeRetryWarning lets the next call through", () => { + recordBashAttempt("npm run dev", "/x", true, "fail"); + expect(detectImmediateRetry("npm run dev", "/x")).not.toBeNull(); + acknowledgeRetryWarning("npm run dev", "/x"); + // Next call should NOT see the warning + expect(detectImmediateRetry("npm run dev", "/x")).toBeNull(); + }); + + test("warning report includes diagnostic instructions", () => { + recordBashAttempt("vite", "/site", true, "Watchpack EMFILE"); + const w = detectImmediateRetry("vite", "/site")!; + expect(w.report).toMatch(/diagnose/i); + expect(w.report).toMatch(/different/i); + expect(w.report).toMatch(/read more state/i); + }); + + test("warning report explains it's not a real failure", () => { + recordBashAttempt("npm run dev", "/x", true, "boom"); + const w = detectImmediateRetry("npm run dev", "/x")!; + expect(w.report).toContain("NOT a real failure"); + }); +}); diff --git a/src/core/bash-spawn-history.ts b/src/core/bash-spawn-history.ts new file mode 100644 index 0000000..d8cf038 --- /dev/null +++ b/src/core/bash-spawn-history.ts @@ -0,0 +1,186 @@ +// KCode - Bash Spawn History +// +// Operator-mind primitive (phase 3): the hypothesis-mismatch loop. +// +// Tracks the last few Bash invocations and, if the model retries the +// EXACT same command in the EXACT same cwd immediately after a failure, +// intercepts the retry and returns a "STOP and reassess" message instead +// of executing. +// +// This is the most important of the three operator-mind pieces because +// it directly attacks the failure mode that bricked the Artemis session: +// blind retry-after-failure. Phase 1 (post-spawn verification) makes +// failures visible; phase 2 (pre-flight) refuses doomed spawns; phase 3 +// breaks the retry loop after the model has already seen one failure. +// +// State is process-local (a singleton Map). Entries expire after the +// retry window (default 8 attempts) so the history never grows +// unbounded even in long sessions. +// +// Scope: detection only fires for commands that match a known +// server-spawn pattern (detectServerSpawn). One-shot commands like +// `ls`, `git status`, `sudo echo X`, `cat package.json` are tracked +// in the history (so they don't pollute the retry window of real +// server spawns) but never trigger the STOP report. This keeps the +// guard focused on the actual failure mode it was built to fix: +// blind retry of broken dev-server spawns. + +import { detectServerSpawn } from "./bash-spawn-verifier.js"; + +const MAX_HISTORY = 64; +const RETRY_WINDOW = 8; + +interface AttemptEntry { + /** Normalized key — see makeKey(). */ + key: string; + /** Original command (unnormalized) for the diagnostic report. */ + command: string; + /** Working directory the command ran in. */ + cwd: string; + /** Was the result an error? */ + isError: boolean; + /** First ~400 chars of the error output (for the "you saw THIS" reminder). */ + errorTail: string; + /** Monotonic attempt index for "N attempts ago" reasoning. */ + index: number; +} + +let _attempts: AttemptEntry[] = []; +let _nextIndex = 0; + +function normalizeCommand(command: string): string { + return command + .trim() + .replace(/\s+/g, " ") + .replace(/\bPORT=\d+/g, "PORT=N") // port-only changes still count as same intent + .replace(/--port[=\s]\d+/g, "--port N"); +} + +function makeKey(command: string, cwd: string): string { + return `${cwd}|${normalizeCommand(command)}`; +} + +// ─── Recording ───────────────────────────────────────────────────── + +export function recordBashAttempt( + command: string, + cwd: string, + isError: boolean, + errorTail: string, +): void { + _attempts.push({ + key: makeKey(command, cwd), + command, + cwd, + isError, + errorTail: errorTail.slice(0, 400), + index: _nextIndex++, + }); + // Bound the history. Drop the oldest. + if (_attempts.length > MAX_HISTORY) { + _attempts = _attempts.slice(-MAX_HISTORY); + } +} + +// ─── Detection ───────────────────────────────────────────────────── + +export interface RetryWarning { + /** The previous failed attempt for the same (cmd, cwd). */ + previous: AttemptEntry; + /** How many Bash calls ago the previous failure was. */ + attemptsAgo: number; + /** Multi-line operator report — safe to inline as a tool result. */ + report: string; +} + +/** + * Check if the given command is an immediate retry of a recently + * failed identical command in the same cwd. Returns null when the + * command is novel, when the previous attempt succeeded, or when the + * previous attempt is older than RETRY_WINDOW. + * + * If a retry is detected, the caller should return the report as a + * tool result with is_error=true and SKIP execution. Treat the warning + * itself as the "second failure" for fingerprint accounting. + */ +export function detectImmediateRetry( + command: string, + cwd: string, +): RetryWarning | null { + // Phase 3 is scoped to server-spawn commands only — it exists to + // break the dev-server retry loop pattern. Sudo prompts, file ops, + // builds, tests, etc. should never see this warning. + if (!detectServerSpawn(command)) return null; + + const key = makeKey(command, cwd); + // Search backward for the most recent entry with this key + for (let i = _attempts.length - 1; i >= 0; i--) { + const e = _attempts[i]!; + if (e.key !== key) continue; + // Found the previous occurrence + const attemptsAgo = _nextIndex - e.index; + if (!e.isError) return null; // last time it WORKED, retry is fine + if (attemptsAgo > RETRY_WINDOW) return null; // too old, allow + + const lines: string[] = []; + lines.push(`✗ STOP. You are retrying a command that just failed.`); + lines.push(``); + lines.push(` command: ${e.command}`); + lines.push(` cwd: ${e.cwd}`); + lines.push( + ` failed: ${attemptsAgo === 1 ? "1 Bash call ago" : `${attemptsAgo} Bash calls ago`}`, + ); + lines.push(``); + lines.push(` The previous failure said:`); + const tail = e.errorTail.split("\n").slice(0, 8); + for (const ln of tail) lines.push(` ${ln}`); + lines.push(``); + lines.push(` Retrying without changing anything will fail the same way and waste a turn.`); + lines.push(` Before re-issuing this command you MUST do ONE of:`); + lines.push(` 1. Diagnose: explain in one sentence what would be different this time.`); + lines.push(` (e.g. "I just killed the conflicting process" or "I added the missing file")`); + lines.push(` 2. Change the command: different cwd, different args, different tool.`); + lines.push(` 3. Read more state first (ls / ss / ps / cat the failing file).`); + lines.push(``); + lines.push(` This message is NOT a real failure of the command — KCode skipped`); + lines.push(` execution to protect you from a tight retry loop. The next attempt`); + lines.push(` will run normally.`); + + return { previous: e, attemptsAgo, report: lines.join("\n") }; + } + return null; +} + +// ─── Test helpers ────────────────────────────────────────────────── + +/** Wipe all history. Use in tests. */ +export function clearBashHistory(): void { + _attempts = []; + _nextIndex = 0; +} + +/** Read-only snapshot of the history, oldest first. Use in tests. */ +export function snapshotBashHistory(): readonly AttemptEntry[] { + return _attempts.slice(); +} + +// ─── Escape hatch ────────────────────────────────────────────────── + +/** + * After detectImmediateRetry returns a warning AND the model issues + * the same command yet again, we still want to allow execution (the + * model may legitimately know something we don't). Call this from the + * caller AFTER showing the warning once — it bumps the entry's index + * so the warning won't fire on the very next attempt. + */ +export function acknowledgeRetryWarning(command: string, cwd: string): void { + const key = makeKey(command, cwd); + for (let i = _attempts.length - 1; i >= 0; i--) { + const e = _attempts[i]!; + if (e.key === key) { + // Bump the entry to "now" so the next call sees attemptsAgo=0 and skips + _attempts.push({ ...e, index: _nextIndex++, isError: false }); + return; + } + } +} diff --git a/src/tools/bash.ts b/src/tools/bash.ts index fd61dc0..d6460bd 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -185,7 +185,56 @@ const SECURITY_TOOLS: Record): Promise { + const command = String((input as { command?: unknown }).command ?? ""); + const cwd = process.cwd(); + + // Phase 3: detect immediate retry of a command that just failed + if (command) { + try { + const { detectImmediateRetry, acknowledgeRetryWarning } = await import( + "../core/bash-spawn-history.js" + ); + const warning = detectImmediateRetry(command, cwd); + if (warning) { + // After showing the warning once, mark it acknowledged so the + // very next attempt (the model's actual response to the warning) + // runs normally without seeing it again. + acknowledgeRetryWarning(command, cwd); + return { + tool_use_id: "", + content: warning.report, + is_error: true, + }; + } + } catch (err) { + log.debug("tool", `bash-spawn-history detect failed (non-fatal): ${err}`); + } + } + + const result = await _executeBashInner(input); + + // Record this attempt for future retry detection + if (command) { + try { + const { recordBashAttempt } = await import("../core/bash-spawn-history.js"); + recordBashAttempt(command, cwd, result.is_error ?? false, String(result.content ?? "")); + } catch (err) { + log.debug("tool", `bash-spawn-history record failed (non-fatal): ${err}`); + } + } + + return result; +} + +async function _executeBashInner(input: Record): Promise { const { command, timeout, run_in_background, sandbox } = input as unknown as BashInput & { sandbox?: boolean; };