diff --git a/pyproject.toml b/pyproject.toml index f9e6ae1..48ae420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "localcode" -version = "0.3.3" +version = "0.3.4" description = "High-performance AI coding on consumer hardware." readme = "README.md" requires-python = ">=3.10" diff --git a/src/localcode/__init__.py b/src/localcode/__init__.py index 11b937b..3c423fb 100644 --- a/src/localcode/__init__.py +++ b/src/localcode/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/src/localcode/agent/recovery.py b/src/localcode/agent/recovery.py index 5fb9e0d..9f511d0 100644 --- a/src/localcode/agent/recovery.py +++ b/src/localcode/agent/recovery.py @@ -365,36 +365,36 @@ def churn_nudge_for(signal: ChurnSignal) -> str: Names the concrete subject (file path / command) + count so the model sees what it's thrashing on, then tells it the ONE thing to do instead. """ + # NOTE: these are FORWARD-ONLY imperatives. They deliberately contain NO + # self-referential loop language ("you're going in circles", "you've + # rewritten X N times", "thrashing", "stop planning"). A small model + # parrots whatever we inject: injecting "you're going in circles" as a + # user-role message makes it reply "You're right, I've been going in + # circles" — which then sits in its own context and self-conditions the + # loop deeper (arXiv:2509.09677). So we tell it the NEXT concrete action + # and nothing about the failure it just had. if signal.mode is ChurnMode.FILE_REWRITE: return ( - f"SYSTEM: You've rewritten {signal.subject} {signal.count} times " - "this turn. Stop rewriting it. Read the ACTUAL error output from " - "the last failure, identify the specific line/cause, and make ONE " - "targeted edit_file change to fix that — do not overwrite the whole " - "file again." + f"SYSTEM: {signal.subject} is written — move on to the next file. " + "If it has a specific error, make ONE targeted edit_file change to " + "the exact line; otherwise create the next file the task needs." ) if signal.mode is ChurnMode.COMMAND_FAILURE: return ( - f"SYSTEM: `{signal.subject}` has failed {signal.count} times this " - "turn. Re-running it will not help. Read its error output line by " - "line, fix the ROOT CAUSE (a missing dependency, a syntax error in " - "a config/source file, a wrong path), and only then run it again." + f"SYSTEM: `{signal.subject}` won't succeed as-is. Read its error " + "output, fix the root cause (missing dependency, a syntax error in a " + "config/source file, a wrong path) with an edit, then run it once." ) if signal.mode is ChurnMode.PLANNING_SPIN: return ( - "SYSTEM: You've spent several rounds planning and re-deriving the " - "same approach without changing a single file or running a build. " - "You've planned enough. Take ONE concrete action NOW: create or " - "edit the most important file for the next step, then build/run to " - "verify it. Do not restate the plan — execute the first step of it." + "SYSTEM: Take the next concrete action now — create or edit the next " + "file the task needs, then run the build to verify. Write code, not " + "a plan." ) # INVESTIGATION_SPIN return ( - "SYSTEM: Several rounds of reading/listing/searching without WRITING " - "anything — you're going in circles. STOP exploring. If the task is to " - "build something, create the target file and write real code THIS round " - "with write_file/edit_file; do NOT read, ls, find, cat, or grep again. " - "Leftover files on disk are NOT your progress (may be an unrelated run) — " - "only files YOU write count. If you truly lack info only the user has, " - "ask ONE focused question; otherwise start writing now." + "SYSTEM: Take a concrete action now — create the next file the task " + "needs and write real code with write_file/edit_file. Files already on " + "disk that you did not create are not part of your task. If you truly " + "lack information only the user has, ask ONE focused question." ) diff --git a/src/localcode/runtime.py b/src/localcode/runtime.py index 65d62c4..0f94360 100644 --- a/src/localcode/runtime.py +++ b/src/localcode/runtime.py @@ -168,6 +168,21 @@ def _log_disconnect_context(stage: str, error: Any, *, mid_stream: bool = False) diag["stage"] = stage diag["mid_stream"] = mid_stream diag["error"] = err_str[:500] + # Capture the SERVER's own last words. llama-server prints the real cause + # (ggml/Metal allocation failure, GGML_ASSERT, OOM) to server.log right + # before it dies; without this we only see httpx's "connection dropped" + # and are left guessing. The tail is the actual diagnosis. + _server_tail = "" + try: + from .paths import global_state_dir + _lp = global_state_dir() / "server.log" + if _lp.exists(): + _lines = _lp.read_text(errors="replace").splitlines() + _server_tail = "\n".join(_lines[-15:])[-1500:] + except Exception: + _server_tail = "" + if _server_tail: + diag["server_log_tail"] = _server_tail try: from .server_manager import _lifecycle_log as _ll _ll("server_disconnect", **diag) @@ -186,6 +201,8 @@ def _log_disconnect_context(stage: str, error: Any, *, mid_stream: bool = False) f"pressure_kill={diag.get('pressure_kill')} " f"running={diag.get('running')} mid_stream={mid_stream} " f"free_mb={diag.get('free_mb')} error={err_str[:300]}\n" + + (f" server.log tail:\n " + + _server_tail.replace("\n", "\n ") + "\n" if _server_tail else "") ) except Exception: pass @@ -2064,6 +2081,25 @@ def _payload( # revert this hunk and accept occasional rephrase as a # model property of Qwen3.6 IQ2_M. payload["repeat_penalty"] = 1.05 + # Forward the EOS-NEUTRAL anti-loop samplers that were previously + # computed into `opts` and then DROPPED (they only went to the dead + # ollama-shaped payload below). DRY penalises repeated N-GRAMS — + # not single tokens — so unlike repeat_penalty it does NOT suppress + # the EOS/sentence-end tokens that caused the 2026-04-29 paraphrase + # regression; min_p trims the low-probability tail that token- + # collapse loops feed on. llama-server has DRY OFF by default, so + # NOT forwarding these is a real root cause of the repeat-collapse + # loops. We deliberately do NOT re-add the aggressive repeat_penalty + # /top_k/top_p bundle (that was the regression). A/B back to + # temperature-only with LOCALCODE_SAMPLER_MINIMAL=1. + import os as _os + if _os.environ.get("LOCALCODE_SAMPLER_MINIMAL") != "1": + payload["dry_multiplier"] = opts["dry_multiplier"] + payload["dry_base"] = opts["dry_base"] + payload["dry_allowed_length"] = opts["dry_allowed_length"] + payload["dry_penalty_last_n"] = opts["dry_penalty_last_n"] + if opts.get("min_p", 0): + payload["min_p"] = opts["min_p"] if "num_predict" in opts: _np = opts["num_predict"] # llama-server treats max_tokens=-1 as "use default", which on @@ -2080,8 +2116,11 @@ def _payload( # outbound chat-completions payload so we can verify DRY is # actually being forwarded. Set LOCALCODE_DEBUG_SAMPLERS=1 # to enable. Self-disables after one log to keep noise down. - import os, logging - if os.environ.get("LOCALCODE_DEBUG_SAMPLERS") == "1" and not getattr(self, "_logged_samplers", False): + import logging + # Always log the ACTUAL forwarded samplers once per session (was + # opt-in) so we can correlate looping with the real server config — + # the "control this better" record. Cheap: fires exactly once. + if not getattr(self, "_logged_samplers", False): sampler_keys = ( "temperature", "top_p", "top_k", "min_p", "repeat_penalty", "repeat_last_n", diff --git a/src/localcode/server_manager.py b/src/localcode/server_manager.py index 990151c..4cefdc3 100644 --- a/src/localcode/server_manager.py +++ b/src/localcode/server_manager.py @@ -71,12 +71,9 @@ lifecycle_log_path, ) -# PID file stays GLOBAL — only one llama-server runs on the machine -# (single port, single GPU memory budget). When you `cd` between -# projects mid-session, the running server stays serving you. The -# per-project lifecycle log records a fresh `server_started` entry -# on next launch from the new project. See paths.py for the full -# global-vs-project split rationale. +# PID file stays GLOBAL — only one llama-server runs per machine (single port, +# single GPU budget), so it keeps serving you when you `cd` between projects. +# See paths.py for the global-vs-project split rationale. PID_FILE = server_pid_file() DEFAULT_PORT = 8081 # Preferred-range scan (8081-8099) before falling through to an OS-assigned @@ -177,13 +174,9 @@ def _rewrite_port_arg(cmd: list[str], port: int) -> list[str]: STUCK_SERVER_MARKER = stuck_server_marker_path() -# Persistent (append-only) lifecycle log for every server start, stop, -# pressure-kill, and recovery event. Distinct from the per-session -# `~/.local/share/localcode/server.log` (which the setup screen -# overwrites with `open(..., "w")` on every launch and so loses -# inter-session context). Per-project (lives at -# `/.localcode/lifecycle.log`) so each project has its -# own diagnostic timeline. +# Persistent (append-only) lifecycle log for every server start/stop/kill/ +# recovery event. Per-project (`/.localcode/lifecycle.log`) so +# each project keeps its own diagnostic timeline across sessions. LIFECYCLE_LOG = lifecycle_log_path() @@ -400,12 +393,11 @@ def start(self, cmd: list[str], model_path: str, port: int = DEFAULT_PORT, had_prior = self._process is not None _prev_model = self._model_path # capture before shutdown clears it self._shutdown_locked() - # Wait for the OLD server's memory to actually release before - # spawning the new one: the kernel may take 1-3 s to free wired - # Metal memory after the child reaps, and spawning the new ~10 GB - # server in that window double-commits and trips the pressure - # monitor on a 16 GB Mac. Best-effort — targets ~11 GB free, times - # out at 8 s, and proceeds anyway (refusing would be worse). + # Wait for the OLD server's memory to release before spawning the + # new one: the kernel takes 1-3 s to free wired Metal memory after + # the child reaps, and spawning into that window double-commits and + # trips the pressure monitor on a 16 GB Mac. Best-effort — targets + # ~11 GB free, times out at 8 s, proceeds anyway. if had_prior: # Only a genuine model change is "model_switch"; a same-model # relaunch (crash/disconnect recovery) is "server_restart". @@ -430,20 +422,35 @@ def start(self, cmd: list[str], model_path: str, port: int = DEFAULT_PORT, # AGX driver may read the env at first Metal touch — setting it in # the parent env before spawn is the belt-and-suspenders approach. env.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1") + # Capture the server's OWN stdout/stderr to server.log (this runtime + # path used to send both to /dev/null, discarding llama-server's + # final ggml/Metal error on the recurring -9). Append per-spawn. + log_fh = None + try: + from .paths import global_state_dir + log_fh = open(global_state_dir() / "server.log", "a", buffering=1) + log_fh.write(f"\n===== llama-server spawn (port {port}) =====\n") + except Exception: + log_fh = None + try: # close any prior spawn's handle before replacing it + if getattr(self, "_log_fh", None) is not None: + self._log_fh.close() + except Exception: + pass + self._log_fh = log_fh self._process = subprocess.Popen( cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=(log_fh or subprocess.DEVNULL), + stderr=subprocess.STDOUT if log_fh else subprocess.DEVNULL, start_new_session=True, # process group leader → killpg works env=env, ) self._model_path = model_path self._port = port - # Layer 1: kernel-enforced memory ceiling via jetsam. If - # llama-server tries to wire more than the budget allows, - # macOS kernel kills it BEFORE the vm_fault wait-chain can - # form. This is the hard backstop — sanctioned Apple API. + # Layer 1: kernel-enforced memory ceiling via jetsam — the hard + # backstop (sanctioned Apple API) that kills llama-server before it + # can push the system over the edge. try: from .memory_guard import ( set_jetsam_highwater, recommended_jetsam_limit_mb, @@ -467,14 +474,10 @@ def start(self, cmd: list[str], model_path: str, port: int = DEFAULT_PORT, except Exception: self._pressure_thread = None self._write_pid_file(self._process.pid) - # Log the FULL command we launched with so future debugging - # can answer "what flags was the server using during this - # session?" — needed because subtle flags like - # `--lookup-cache-dynamic` or `--spec-type ngram-mod` cause - # repetition pathologies that look like model bugs but are - # actually decode-time speculative-cache feedback loops. - # Truncated to 4000 chars to keep the event small; that's - # enough for ~80 args. + # Log the FULL launch command so debugging can answer "what flags + # was the server using?" — subtle flags (e.g. --spec-type ngram-mod) + # cause repetition pathologies that look like model bugs. Truncated + # to 4000 chars (~80 args) to keep the event small. _flags_str = " ".join(str(c) for c in cmd)[:4000] _lifecycle_log("server_started", pid=self._process.pid, port=port, model=Path(model_path).name, diff --git a/tests/test_churn_detection.py b/tests/test_churn_detection.py index 3b72dfa..3f186c0 100644 --- a/tests/test_churn_detection.py +++ b/tests/test_churn_detection.py @@ -80,8 +80,10 @@ def test_investigation_spin_outranks_planning_spin(): def test_planning_spin_nudge_is_actionable(): sig = detect_churn({}, {}, 0, planning_streak=CHURN_PLANNING_STREAK_LIMIT) text = churn_nudge_for(sig) - assert "planned enough" in text.lower() - assert "concrete action" in text.lower() or "execute" in text.lower() + # Forward-only imperative: tells it the next action, with NO echo-able + # self-critical loop language ("planned enough") a small model would parrot. + assert "concrete action" in text.lower() or "write code" in text.lower() + assert "planned enough" not in text.lower() and "circles" not in text.lower() def test_command_failure_takes_precedence_over_file_rewrite():