Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/core/bash-spawn-history.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
186 changes: 186 additions & 0 deletions src/core/bash-spawn-history.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
92 changes: 92 additions & 0 deletions src/core/bash-spawn-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Bun.serve> | 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<typeof Bun.serve> | 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);
});
});
Loading
Loading