diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e10404..26e4e521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to LocalCode will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.4.7 — 2026-09-26 + +### Fixed + +- **No more "Interrupted" seconds after being asked to fix something.** The + loop breaker that stops a session making no progress kept counting from an + earlier nudge, so when a gate sent the model back to replace a placeholder or + finish a todo, the breaker could stop it 9 seconds later while it was reading + the file it had just been told to fix. A gate's instruction now restarts the + budget. When the breaker does stop a session it says so, with the round count + and the files changed, instead of a bare "Interrupted". + ## 0.4.6 — 2026-09-26 ### Fixed @@ -23,7 +35,7 @@ All notable changes to LocalCode will be documented here. The format follows turn and dropped again on the next, the planning rule followed the same switch, and the open-todo list was rewritten into the system prompt on every update. Each change made the server re-read the whole conversation, twice - per turn (15-23 s at 30k tokens on an M5). The system prompt is now constant + per turn (15-23 s at 30k tokens). The system prompt is now constant for a session, and todos travel with the user turn. Measured on Qwen3.8 27B: requests after the first tool call re-read 23-310 tokens (0.3-0.8 s) instead of 6-7k (10-15 s). @@ -57,9 +69,9 @@ All notable changes to LocalCode will be documented here. The format follows system prompt and tool schemas, 3-5k tokens) before you have typed anything. The runtime learns that prefix from the first turn you run with a model (it keeps the token ids only, a few KB per model, under the run dir) and - replays it on every later load. Measured on an M5: the first request's + replays it on every later load. Measured on Apple silicon: the first request's prompt reading drops from 2.7 s to 0.5 s on Gemma 4 12B and from 6.3 s to - under 0.1 s on Qwen3.8 27B; on a 16 GB M1 that is most of the wait before + under 0.1 s on Qwen3.8 27B; on a 16 GB Mac that is most of the wait before the first token. Turns after the first were already cached. Off switch: `LOCALCODE_PROMPT_WARMUP=0`. The environment block carries today's date, so the first load of a day still reads the tail after it once. diff --git a/pyproject.toml b/pyproject.toml index cf5e2714..676f517d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "localcode" -version = "0.4.6" +version = "0.4.7" 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 2b91e283..031eb3c7 100644 --- a/src/localcode/__init__.py +++ b/src/localcode/__init__.py @@ -8,4 +8,4 @@ try: __version__ = _pkg_version("localcode") except PackageNotFoundError: # not installed (e.g. raw source checkout) - __version__ = "0.4.6" + __version__ = "0.4.7" diff --git a/src/localcode/bootstrap.py b/src/localcode/bootstrap.py index a3f7953b..888ca12c 100644 --- a/src/localcode/bootstrap.py +++ b/src/localcode/bootstrap.py @@ -819,6 +819,11 @@ def _apply_progress(key: str, line: str) -> None: entry = _DOWNLOADS.get(key) if entry is None: return + # A progress line that arrives after the download finished (a worker + # flushing its last output, or a stale worker for the same key) must + # never move a finished entry backwards: 'done' stays at 100. + if entry.get("status") in ("done", "failed"): + return if downloaded_mb is not None: entry["downloaded_mb"] = downloaded_mb if total_mb is not None: diff --git a/src/localcode/ui/plugin/localcode.ts b/src/localcode/ui/plugin/localcode.ts index bcfdb697..70cc47e5 100644 --- a/src/localcode/ui/plugin/localcode.ts +++ b/src/localcode/ui/plugin/localcode.ts @@ -360,6 +360,15 @@ class PlateauTracker { if (ev.tool === "bash" && isCheckCommand(String(ev.args?.command ?? "")) && this.mem.lastCheckPassed) this.lastPassRound = this.round + 1; } + /** A gate just gave the model a NEW instruction (finish a todo, replace a + * placeholder, run the build). That earns a fresh no-progress budget: the + * model was stopped 9 s after being sent back to fix a placeholder because + * the counter still ran from a nudge 48 minutes earlier. */ + freshInstruction(): void { + if (this.stopped) return; + this.noProgress = 0; this.nudged = false; this.passNudged = false; + } + /** Close the current round (a step-finish, or session.idle as a flush). Steps without tools are ignored. */ endRound(): PlateauDecision { if (!this.open || this.stopped) return "none"; @@ -487,6 +496,11 @@ const LocalcodePlugin: Plugin = async ({ client, directory }) => { await client.tui.showToast({ body: { title: "Repair stalled", message: "The same check keeps failing. Changes are preserved; this task is incomplete.", variant: "warning", duration: 12000 } }); } catch (e: any) { plog(`status notification failed: ${e?.message ?? e}`); } } + if (!plateau.repairStopped) { + try { + await client.tui.showToast({ body: { title: "Stopped: no new progress", message: `${plateau.round} tool rounds without new progress after being asked to finish. ${files.length ? `${files.length} file(s) changed so far. ` : ""}Send a message to continue.`, variant: "warning", duration: 15000 } }); + } catch (e: any) { plog(`status notification failed: ${e?.message ?? e}`); } + } plog(`stopping session after ${plateau.round} tool rounds with no progress since the nudge; ` + `files delivered ${files.length}${files.length ? ` (${files.slice(0, 12).join(", ")}${files.length > 12 ? ", ..." : ""})` : ""}, ` + `open items ${open.length}${open.length ? `: ${open.map((t) => t.content).join("; ")}` : ""}, ` + @@ -616,6 +630,7 @@ const LocalcodePlugin: Plugin = async ({ client, directory }) => { continueCount += 1; const next = open.find((t) => t.status === "in_progress") ?? open[0]; log(`${open.length} todo(s) still open — continuing with: ${next.content}`); + plateau.freshInstruction(); await nudge(sessionID, `${NUDGE_PREFIX} You still have ${open.length} unfinished todo(s). The task is NOT complete — do not stop. Continue now with: ${next.content}. Mark a todo completed via todowrite only when it is genuinely done, and keep going until every item is completed.`); return; } @@ -629,6 +644,7 @@ const LocalcodePlugin: Plugin = async ({ client, directory }) => { if (stubs.length) { stubNudgeDone = true; log(`placeholders found in ${stubs.length} line(s) — sending back`); + plateau.freshInstruction(); await nudge(sessionID, `${NUDGE_PREFIX} your changes still contain placeholders — the user asked for complete, working features, not stubs:\n${stubs.join("\n")}\nImplement each one for real (reopen it as a todo if needed), or tell the user explicitly which requirement you cannot meet and why.`); return; } @@ -643,6 +659,7 @@ const LocalcodePlugin: Plugin = async ({ client, directory }) => { const errors = runCheck(root, cmd); if (errors) { buildVerifyNudges += 1; + plateau.freshInstruction(); log(`${cmd.join(" ")} failed in ${root} — sending errors back`); await nudge(sessionID, `${NUDGE_PREFIX} the project's typecheck/build (\`${cmd.join(" ")}\`, in ${root}) was run for you and reported errors. FIX each one with targeted edits, then finish. Do not claim it works until these are gone:\n\n${errors}`); return; diff --git a/tests/ui/plateau.test.ts b/tests/ui/plateau.test.ts index a6013aa4..b2f46c4a 100644 --- a/tests/ui/plateau.test.ts +++ b/tests/ui/plateau.test.ts @@ -458,3 +458,26 @@ describe("repeated check failures", () => { expect(checkPassed(check("", 0, "npx vite build | head -100"))).toBe(false); }); }); + + +test("a gate's fresh instruction resets the no-progress budget instead of stopping seconds later", () => { + const tracker = new PlateauTracker(); + const noop = { tool: "grep", args: { pattern: "placeholder" }, output: "x", metadata: {} }; + const rounds = (n: number) => { const out: string[] = []; for (let k = 0; k < n; k++) { tracker.observe(noop); out.push(tracker.endRound()); } return out; }; + const first = rounds(PLATEAU_MIN_ROUND + PLATEAU_NUDGE_AFTER + 2); + expect(first).toContain("nudge"); + expect(first).not.toContain("stop"); + const nudgeAt = first.indexOf("nudge"); + const sinceNudge = first.length - nudgeAt - 1; + // one round short of the stop, a gate sends the model back with new work + rounds(PLATEAU_STOP_AFTER - 1 - sinceNudge); + expect(tracker.stopped).toBe(false); + tracker.freshInstruction(); + const after = rounds(PLATEAU_STOP_AFTER - 1); + expect(after).not.toContain("stop"); + expect(tracker.stopped).toBe(false); + // without the reset the same rounds would have stopped it + const control = new PlateauTracker(); + const c = (n: number) => { const out: string[] = []; for (let k = 0; k < n; k++) { control.observe(noop); out.push(control.endRound()); } return out; }; + expect(c(PLATEAU_MIN_ROUND + PLATEAU_NUDGE_AFTER + 2 + PLATEAU_STOP_AFTER + PLATEAU_STOP_AFTER)).toContain("stop"); +});