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
18 changes: 15 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
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.4.6"
version = "0.4.7"
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
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions src/localcode/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/localcode/ui/plugin/localcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("; ")}` : ""}, ` +
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions tests/ui/plateau.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading