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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/localcode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ["__version__"]

__version__ = "0.3.3"
__version__ = "0.3.4"
42 changes: 21 additions & 21 deletions src/localcode/agent/recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
43 changes: 41 additions & 2 deletions src/localcode/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
69 changes: 36 additions & 33 deletions src/localcode/server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# `<project_root>/.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 (`<project_root>/.localcode/lifecycle.log`) so
# each project keeps its own diagnostic timeline across sessions.
LIFECYCLE_LOG = lifecycle_log_path()


Expand Down Expand Up @@ -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".
Expand All @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions tests/test_churn_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading