Unattended Agents on oMLX and gentle-shell
Logos identify the projects this report is about. It is an independent contribution.
Introduction
This article addresses a concrete question: is it possible to complete real work from beginning to end using local models, consumer hardware, and only open-source technology?
To investigate it, we developed a documentation pilot: turning PDF manuals and older screenshots into wiki documentation, with images of the new ADempiere version and an assistant that answers questions. The implementation combined the oMLX inference server, the Gentle Shell agent harness, and open-weight models from the Qwen family, running on a personal workstation.
I am the lead maintainer of ADempiere, an open-source project for ERP, CRM, and SCM. Its official repository provides public access to the source code and development history, giving readers context for the project behind this pilot. I also lead a company specializing in the development, support, and professional maintenance of solutions based on this technology. That experience helped us choose a concrete, useful task with verifiable results.
The purpose was not to compare this configuration with frontier models or tools such as Claude Code or Codex, but to establish whether it could complete the proposed work, identify the obstacles, and document the conditions needed to make it possible.
Below, I share the experience, the problems encountered, the solutions developed, and the results obtained, together with the limitations of the evidence.
Executive summary
We tested whether local models on consumer hardware, using open-source tools, could carry out useful documentation work without constant human intervention. The pilot used Qwen models served by oMLX on an Apple M5 Max with 128 GB of memory, with pi and gentle-shell coordinating agents working on an ADempiere wiki.
- For oMLX: TurboQuant batched-decode and restored-prefix prefill fixes improved measured performance. A proposed pause-and-resume stack kept requests alive under forced memory pressure.
- For gentle-shell: A todo-driven system-prompt rewrite caused substantial cache loss. The tested fix moves todo state into conversation messages to keep the system prompt stable.
With the proposed server and harness fixes applied during the pilot, the project met its objective: the documentation was completed from beginning to end in wiki format, with images of the new ADempiere version and an assistant that answers questions, using local models, consumer hardware and open-source tooling. The benchmarks document improvements in performance, memory use and stability that supported that outcome; they do not independently audit the wiki's content. Historical batch results, newer upstream comparisons and an unpaired live run answer different questions.
Results are limited to one Apple consumer workstation configured for local AI and one model family. Upstream merged PR2 as #4442 and PR1's fix as #4453 on 2026-10-12; the other fixes are proposals, not claims of upstream adoption, and none is a guarantee for smaller machines.
Detailed contributions, benefits and acknowledgements
Contribution overview
What we contribute
To oMLX
- PR1: TurboQuant batched decode. Each left-padded row attends only its own slice, in decode and in MTP verify. A bug fix. Upstream closed it in favor of #4453, which builds the same fix on the #4408 kernel and lists us as co-author (merged 2026-10-12). Details
- PR2: prefill after a restored TurboQuant prefix. A long suffix is prefilled over a dense copy of the restored prefix, then re-quantized for decode. A bug fix, merged upstream as #4442 (2026-10-12). Details
- Pause and resume instead of abort. Under memory pressure a request keeps its prefill progress and parks, decoding rows pause, and both resume when memory frees; a request that can never fit gets a structured error, and image prompts become parkable. Off by default or gated, opened on 2026-10-12 as a stack of 12 pull requests, #4478 to #4489. Details
- Prefix-cache fixes A1 and A2. Mid-conversation system messages no longer rewrite the prompt head (A1), and a client's synthetic messages no longer strip thinking in the middle of an agent loop (A2). Proposed separately, after the bug fixes. Details
- Observability and a memory bound. Eviction summary, per-request cache outcome and TTFT breakdown logs, and
memory.mlx_cache_limit_gbto bound the Metal buffer cache, as separate later proposals. Details
To gentle-shell
- The todo fix (#1367). Todo state moves out of the system prompt into hidden conversation messages sent only when the list changes, and the stale reminder fires once, so the system prompt stays byte-stable within a session. One commit, ready on
fix/todo-prompt-cache-1367. Details - A prompt-stability invariant test. It asserts that each request extends the previous one, and can be generalized to every extension that touches the system prompt. Details
- An end-to-end cache check and a payload recorder (optional). They catch prompt rewrites against a local server that reports cached tokens, and make "it got slow" reports reproducible. Details
- Four more harness findings, with evidence. YOLO directive and memory addendum toggles in the system prompt (raised in #1367), the worker role without
agent_browser, and squad workers that ignore the pinned model. Details
Why it is worth integrating. Paired benchmarks compared the fork with the upstream version named in each section, one build at a time. Recorded traffic and the live run provide observational evidence, not paired upstream comparisons. Run counts and limitations are stated with each result. Every behaviour change was written test-first; about two thirds of the pause-resume cycle's changed lines are tests. The work is split into small, reviewable pull requests with a stated merge order. Beyond the two bug fixes, every slice of the pause-resume stack is off by default or gated on an explicit memory ceiling, so with every flag off it behaves as main on these paths. The order and shape are proposals; we will adapt them to your preferences.
Benefits for oMLX
- Faster, leaner batched decode (PR1). Batched TurboQuant decode 5–12× faster per row (from about 1.5 tok/s per row on
main25aebb3e); B=3 phase 345 → 66 s with MTP on and 780 → 65 s with MTP off; peak 11–15 GB lower. Upstream's #4453, built from this diagnosis, reports 62–65 tok/s per row with three concurrent requests (maintainer's measurement, Qwen3.6-35B-A3B with TQ4). Evidence - Restored prefixes no longer slow prefill (PR2). Suffix prefill after a restored TurboQuant prefix: 2.41× → 0.92× of a cold prefill. With the fork's progress commit, a re-issue after a cancel takes 80–82 s instead of 192–193 s. Evidence
- Pause instead of abort (opt-in stack, historical C7 run against b7c7929e). Under forced pressure 9 of 9 requests finish with 0 aborts, against 0 of 9; tokens are identical after 12 pauses with MTP off; a request that can never fit gets a structured 400 in 0.1 s. Evidence
- No lost work. A cancelled prefill keeps 73,728 committed tokens instead of 0. Evidence
- Better cache reuse for any client (A1, A2). A2 removes a cause worth about 0.94M re-prefilled tokens in 1.5 days; image turns reach 97.4–99.7% hits. A1 keeps mid-conversation system messages from rewriting the prompt head. Evidence
- Bounded memory and observability.
mlx_cache_limit_gbbounds the Metal buffer cache (evidence: the P2 peak); the eviction summary, per-request cache outcome and TTFT breakdown support every live measurement here. Evidence
Benefits for gentle-shell
- A byte-stable system prompt. System-prompt rewrites 21 of 61 → 0 of 90 requests; 31 of 31 consecutive turns append-only; re-prefills above 20k tokens from 1–2 per hour to 0. Evidence
- Time and compute back. Before the fix, ~1.05M tokens were re-prefilled in ~1.5 days (≈80–85 min of GPU time), and each affected turn waited ≈2–6 min longer. Evidence
- Relevant beyond oMLX. Prompt rewrites can also reduce reuse on other local servers and hosted APIs with prefix caching. Actual savings depend on each backend's cache rules and pricing; they were not measured here. Evidence
- Small and verified. One commit, 388 changed lines (296 of them tests); 63 todo tests pass; an independent review found no blocking issues; #1924's reminder is kept. Evidence
- Regressions caught early. The fix carries a prompt-stability invariant test for the todo tool, which can be generalized to every extension that touches the system prompt. Proposal
status:approved on #1367, so the fix can go in as a pull request, or be folded into another change if you prefer. Details: Proposed contribution.Both projects are open, and that is what let us read the code, measure it and fix what we found. In the same spirit, we would like to give these improvements back rather than keep a private fork. Thank you to jundot for oMLX and to Alan-TheGentleman for gentle-shell, and for the great work each of you shares through your project.
About this report
A field report for the maintainers of two projects: oMLX, the inference server, and gentle-shell, the agent harness. It follows one developer workstation running coding agents unattended: how the work started, how each problem was measured, what was changed in a fork, how it was validated, and which parts we would like to contribute upstream. Both projects were improved with the same method: measure on real traffic, attribute each loss to a cause, fix test-first, and validate against the other side.
Scope. The oMLX work started on v0.7.0 and is now based on v0.7.1.dev1, upstream main 013bc3f3. This is a field report on specific versions, not a general validation of the current fork or of today's upstream main. The C7 runs and the live production run used upstream oMLX main at b7c7929e, the fork branch at the commits named in each section, and gentle-shell main at aa2c0389. Two re-validations are newer: the TurboQuant fix (PR1) on upstream main 25aebb3e, and the whole fork against main c0b1056b, which also surfaced a second upstream TurboQuant defect (PR2). The branches proposed upstream are rebased on main 013bc3f3.
Results and limits at a glance
Read each result with its baseline. Repeated requests within one run are not independent benchmark repetitions. Follow the evidence links for exact settings, logs and commit identities.
| Question | Baseline and runtime | Evidence | Limit |
|---|---|---|---|
| Was the documentation pilot completed end to end? | The author's project outcome | The author confirms that, with the proposed server and harness fixes applied during the pilot, the project completed the wiki documentation with images of the new ADempiere version and an assistant that answers questions, using local models, consumer hardware and open-source tooling. | Reported completion with the proposed fixes applied, not the original configuration or an upstream release containing them; the benchmarks are not an independent content-quality audit. |
| Do batches finish unattended? | Historical v0.7.0 comparison | P5: 3/3 fork batches, 0/3 upstream. Evidence | Measures batch completion separately from the final project outcome; not re-run against newer main. |
| Does PR1 improve batched decode? | main 25aebb3e; same pinned venv | B=3: 5–12× faster per row; 11–15 GB lower peak. One run per build and MTP setting. Evidence | No degeneration reproduced on this baseline; outputs are not token-identical to B=1. |
| Does PR2 improve restored-prefix prefill? | main c0b1056b; pinned venv | Restored/cold suffix ratio: 2.41× → 0.92×. One run per configuration. Evidence | One model and prompt shape; extra dense-cache memory must fit. |
| Do requests survive pressure after the prefill fix? | Fork 4aa66150 against main 013bc3f3; same venv | Text and image decode finish 3/3 on the fork at the compared ceilings; main finishes 0/3. One run per scenario. Evidence | Forced pressure, not typical load; some other upstream scenarios were not re-run. |
| Does the server sustain real agent traffic? | Unpaired fork.5 live run; v0.7.0 app runtime | 333 requests; 94.8% of prompt tokens cached; 0 memory aborts. Evidence | Log window 02:00–04:10; no memory pressure, so pause/resume was not exercised. |
| Does the todo fix stabilize prompts? | Recorded traffic before/after the gentle-shell fix | System-prompt mutations: 21/61 → 0/90 requests; 31/31 append-only pairs. Evidence | Observational samples with different workloads; not a controlled latency or hosted-cost benchmark. |
At a glance
main (d42264b4, 2026-10-11). A tested fix is ready on fix/todo-prompt-cache-1367, tracked in #1367. See the gentle-shell section.- On newer
main(25aebb3e, with its pinned dependencies), PR1 is primarily a performance fix. A B=3 batch of 20k/45k/135k prompts completes on both trees without degeneration, and PR1 makes batched decode 5–12× faster per row, cuts the B=3 phase from 345 to 66 s (MTP on) and from 780 to 65 s (MTP off), and lowers the peak by 11–15 GB. The correctness signal is weaker: with MTP off,main's batched rows leave the deterministic B=1 output at about token 1–2, PR1's at about token 8–10. See the re-validation. - Historical (
mainb7c7929e on the v0.7.0 app runtime): the same batch degenerated in one run (4-gram 0.33/0.29/0.18, 121 GB) and aborted in another (6 memory aborts, 120 GB). This does not reproduce on 25aebb3e.
The use case
The setup is one Apple M5 Max (128 GB) serving one local model to several pi coding-agent sessions. pi runs the gentle-shell harness (package gentle-pi) on top, which adds the agent workflow: orchestration, a todo tool, review integration and subagent delegation. Subagents run as separate pi processes, each with the same harness. The sessions read whole source files and take screenshots. Prompts are large: across 1,260 recorded requests the median was 107k tokens and the p90 168k. Each turn re-sends the growing conversation, so the prefix cache decides whether a turn costs 1 second or several minutes.
The goal was simple to state: leave the machine working until every job finishes. That means using free memory for concurrency, never throwing away completed work, and pausing rather than aborting when memory is tight.
Field illustration from the later fork.5 recordings, not a benchmark of the original configuration.
What broke on stock v0.7.0
- Several concurrent long prompts reached the memory guard's hard watermark. Requests were aborted mid-prefill, all prefill progress was lost, and the agent stopped waiting for a human.
- Raising concurrency made things worse. In a 3-request mixed benchmark, concurrent finished in 538 s against 246 s serial, with 6 aborts.
- When batched decode did run, short rows in a left-padded TurboQuant batch produced degenerate output.
- Prefix-cache reuse was high on average but collapsed in bursts: single-turn losses of 70k–170k tokens.
How it developed
The question
After the 0.7.0 memory-guard tiers landed, the question was whether a request could pause when memory runs out and resume when it frees, instead of aborting. We checked where that belongs, client or server, and settled on both. The pi extension pauses and auto-continues. The server keeps prefill progress and parks instead of aborting.
Premises before code
We wrote six falsifiable premises (P1–P6), each tied to a benchmark that runs against upstream v0.7.0 on the same machine, model and prompts. Nothing counted as done until its premise passed against upstream.
Implementation in small reviewed slices
The work was done test-first, with one reviewable commit per behaviour and an independent review on each slice (four review lenses on the high-risk ones). It went into a fork branch off v0.7.0 and was packaged as a side-by-side app for daily use.
The batching surprise
Real concurrent use exposed the padded-attention corruption and very slow batched TurboQuant decode, about 1.5 tok/s per row. Fixing the correctness bug and adding a single-launch ragged kernel brought per-row decode to about 13 tok/s.
The cache-reuse investigation
Once nothing aborted, the remaining cost was re-prefill. We recorded every request payload with a small client-side recorder and attributed each cache break to a cause. About 2.7M tokens were re-prefilled over roughly 1.5 days. The largest cause was in the gentle-shell harness and two were in the server; all three were fixed.
Harness and server, tested together
Testing the gentle-shell harness against the running oMLX server, with server-side monitoring, exposed a cache invalidation that neither side showed on its own: while todos were open, the harness rewrote its system prompt on every user prompt. The fix went to gentle-shell, and the method became part of this report (details).
Live validation
Recorded traffic and daily use helped validate the changes exercised by those workloads. Structured logs (cache outcome, TTFT breakdown, eviction summary) supported the measurements; features tested only in unit or forcing tests are identified separately.
Pause and resume, end to end
A forcing benchmark showed that the prefill park works but cannot save a batch, because decoding rows and image prompts still aborted. We built the full cycle: decode pause, memory-aware resume, a never-fits error and parkable image prompts. Its first validation run failed in several ways. Each failure was traced in the logs and fixed test-first, until every request finished under forced pressure with 0 aborts.
Live production run
The packaged fork (0.7.0-fork.5) then served real unattended agent work for 1 h 39 min, with the concurrency limit raised live from 3 to 5. It logged 0 memory aborts and served 94.8% of prompt tokens from the prefix cache. Memory stayed far from the pressure line, so pause and resume never fired.
Premises and results
Start with the newer re-validations below. The original comparisons are retained as historical evidence; they are not measurements of today's upstream version.
Historical comparisons: v0.7.0 and main b7c7929e
All runs used an isolated server on a separate port with a fresh cache directory, the same model and the same prompts. They compared upstream v0.7.0 against the fork.
| Premise | Upstream v0.7.0 | Fork | Result |
|---|---|---|---|
| P1 · No lost work (cancel mid-prefill, re-issue) | 0 tokens cached | 73,728 tokens cached | PASS |
| P2 · 2×~130k concurrent, no aborts | 4 aborts · peak 116 GB | 0 aborts · peak 44 GB | PASS |
| P3 · Greedy batch correctness (B=3 vs B=1) | degenerate short rows | no degeneration; divergences only at bf16 near-ties | PASS |
| P4 · Concurrency pays (3 mixed requests) | 538 s vs 246 s serial · 6 aborts | 241 s vs 247 s serial · 34.6 vs 29.7 tok/s aggregate | PASS |
| P5 · Unattended pi batch | 0/3 completed | 3/3 completed | PASS |
| P6 · Single 110k request, no regression | baseline | prefill +1.6% · decode +8.5% | PASS |
P4's wall-clock criterion is close to run-to-run noise: one of five fork runs missed it by 0.2 s. The aggregate-decode criterion passes with margin.
Re-run against main b7c7929e (v0.7.0 app runtime)
We rebased the fork onto upstream main (b7c7929e) and re-ran the premises. Each build ran alone on the machine, through the same runtime (the v0.7.0 app's Python, dependencies and compiled kernels, mlx-vlm 0.7.1), so the two differ only in source.
| Premise | main | Fork rebased on main | Reading |
|---|---|---|---|
| P1 · Re-issue after mid-prefill cancel | 0 cached · 110,031 re-prefilled | 73,728 cached · 36,303 re-prefilled | PASS |
| P2 · 2×~130k concurrent | 0 aborts · peak 116 GB · 1,427 s | 0 aborts · peak 44 GB · 461 s | PASS main survives, but near the memory ceiling and about 3× slower |
| P3 · Greedy batch correctness (B=3 vs B=1) | B=3 batch aborted (6 aborts, 120 GB peak); does not reproduce on 25aebb3e | no row degenerate; 4-gram ratios 1.00 / 1.00 / 0.99 | fork correct divergences at bf16 near-ties |
| P4 · Concurrency pays (3 mixed requests) | 248 s concurrent · 251 s serial · 0 aborts | 227 s concurrent · 224 s serial · 0 aborts | within noise no gain claimed against main |
| P6 · Single 110k request | prefill 755 tok/s · decode 21.1 tok/s | 744 tok/s (−1.4%) · 20.6 tok/s (−2.4%) | within the −5% limit |
Three points to note. First, main no longer aborts in P2 or P4 the way v0.7.0 did. Second, the fork's concurrency gain in P4 is within noise against main. Third, P5 needs the live agent client, so it was not re-run. The clear wins against main b7c7929e on this runtime are no lost work (P1), a much lower memory peak and faster completion under concurrency (P2), and batch correctness (P3). P3 was re-run on newer main for PR1, where the picture changes (below).
Re-validation on newer upstream main
Field illustration of concurrent decode from the fork.5 recordings. The paired B=3 measurements below are separate evidence; this B=5 clip is not their recording.
The evidence above was measured on b7c7929e with the v0.7.0 app runtime. It was re-checked on newer upstream main commits in two rounds: PR1's premise on 25aebb3e (this subsection), then the whole fork against main c0b1056b (next subsection). Every branch proposed for upstream is now rebased on main 013bc3f3 (2026-10-10), with its focused tests and CI's command re-run there (Proposed contribution). The upstream commits between 25aebb3e and 013bc3f3 do not touch the TurboQuant files PR1 changes.
PR1 on 25aebb3e. PR1 was rebased on 25aebb3e at the time, so its premise, P3, was re-run on that tree. main 25aebb3e and PR1 (61bd8a06, two commits on 25aebb3e) ran from one venv built from main's pins: mlx 0.32.3, mlx-vlm 0.7.7, mlx-lm 94cdcae1, transformers 5.17.0. Only the source tree changed between them. The prompts are about 20k / 45k / 135k tokens, byte-identical to the earlier runs, with 512 new tokens and greedy sampling. Three B=3 rows are restored from the prefix cache, decode together, and are compared with the same prompts restored at B=1. One run per build, one server at a time, with the live app quit.
| Build | MTP | Aborts | Peak | Distinct 4-gram, B=3 (20k / 45k / 135k) | Batched decode tok/s per row | B=3 phase | Run wall |
|---|---|---|---|---|---|---|---|
main | on | 0 | 117 GB | 1.00 / 1.00 / 0.99 | 1.54 / 1.53 / 1.49 | 345 s | 1015 s |
| PR1 | on | 0 | 102 GB | 1.00 / 1.00 / 0.98 | 8.35 / 8.83 / 7.84 | 66 s | 682 s |
main | off | 0 | 112 GB | 1.00 / 1.00 / 0.98 | 0.66 / 0.66 / 0.66 | 780 s | 1492 s |
| PR1 | off | 0 | 101 GB | 1.00 / 0.99 / 0.98 | 8.17 / 8.15 / 8.05 | 65 s | 782 s |
- Performance. Neither tree aborts or degenerates. PR1 makes batched decode 5.4× faster per row with MTP on and 12× faster with MTP off, cuts the B=3 phase from 345 to 66 s and from 780 to 65 s, and lowers the process peak by 11–15 GB.
main's 0.66 tok/s with MTP off is what the microbenchmark predicts: about 86 ms per TurboQuant layer on the padded path, times 15 layers. B=1 decode is about 40 / 29 / 17 tok/s with MTP on, the same on both trees, so batched rows remain slower than single rows. - Identity against B=1, MTP off. The B=1 reference is deterministic here: a second B=1 run is identical on both trees.
main's batched rows leave it at about token 1–2 on the 20k and 45k rows; PR1's at about token 8–10, and at about token 45 on the 135k row. Neither is token-identical. Upstream has no logprob dump, so these divergences cannot be classified as near-tie noise or as a defect. The earlier departure onmainis consistent with padded-row attention, not proven by it. - Identity, MTP on. Two B=1 runs already differ from each other, so the B=3 divergences say nothing about batching.
- What changed since the earlier evidence. On b7c7929e with the v0.7.0 app runtime (mlx-vlm 0.7.1),
maindegenerated (4-gram about 0.33 / 0.29 / 0.18 at 121 GB) in one run and aborted (6 memory aborts at 120 GB) in another. Neither reproduces on 25aebb3e. Two things differ: the 52 upstream commits in between (including #3127's exact rollback and memory-guard and decode-fairness changes) and the runtime. With one run per build, which of the two removed it is unknown.
The whole fork against main c0b1056b
The fork was rebased again, onto upstream main c0b1056b (the commit tagged v0.7.1.dev1). Both trees ran from one venv built from main's pins (mlx 0.32.3, mlx-vlm 0.7.7), one server at a time, with the live app quit. The fork (6fbb3274) ran with the fork app's settings; main ran with plain settings. The forced-pressure scenarios are the C7 ones (below): three greedy prompts of about 130k / 115k / 100k tokens started 0.5 s apart, hard_threshold 0.8, the same custom ceiling for both trees. Pooled-buffer reclaim is always on now, because upstream removed the switch that disabled it. One run per scenario.
| Scenario | Ceiling | Fork | main c0b1056b |
|---|---|---|---|
| Text decode | 44 GB | 3/3 0 aborts; 2 prefill parks, both resumed | 0/3 abort-all mid-prefill after 25 s |
| Image decode | 42 GB | 3/3 0 aborts; 2 decode pauses, both resumed (also 3/3 at 44 GB) | 0/3 two abort-alls and one prefill-guard rejection |
| Image prefill | 44 GB | 3/3 0 aborts; 2 prefill parks, both resumed | 3/3 0 aborts |
Never fits (60k prompt, max_tokens 2,000,000) | 42 GB | 400 context_exceeds_capacity in 0.11 s, all 7 fields; capacity endpoint 200; header 288,739 | 200; endpoint 404; no header |
- Identity after a pause, MTP off. Four forced decode pauses per request (12 in total, at output offsets 100, 323, 1044 and 1706) give token-identical output for 3/3 requests.
- Park during a packed prefill. Upstream's packed prefill now prefills concurrent prompts in one forward. A row parked while packed resumed, and all 3 requests finished with 0 aborts. Outputs differ from the alone references only at near-ties (an exact tie and a 0.125-nat margin), and a control at 56 GB without any park diverges at the same tokens. The row that was never parked is token-identical with and without its companion's park.
- A fork defect found and fixed during this round. At 42 GB the first image-decode run rejected a lone request that fits alone, on a memory reading that still held a just-finished request's buffers. The fix re-measures before deciding and renews the request's eviction budget when the rows it waited on leave (strict TDD); the re-run passed at 42 and 44 GB.
Premises, fork against main c0b1056b, same venv:
| Premise | Fork | main c0b1056b | Reading |
|---|---|---|---|
| P1 · Re-issue after mid-prefill cancel | 73,728 cached · TTFT 198.9 s | 0 cached · TTFT 192.2 s | cache PASS, no TTFT gain on this commit; with the prefill fix the re-issue takes 80–82 s against 192–193 s on main (re-validation), traced to an upstream defect, fixed below |
| P2 · 2×~130k concurrent | 2/2 · 0 aborts · peak 51 GB · 564 s | 2/2 · 0 aborts · peak 116 GB · 870 s | PASS lower peak, faster |
| P3 · B=3 batch, MTP on | 13.0 / 12.2 / 10.9 tok/s per row · peak 58 GB | 1.55 / 1.54 / 1.50 tok/s per row · peak 117 GB | no regression no visible cost from #3127's rollback; the fork carries PR1 and the ragged kernel |
| P4 · Concurrency (3 mixed requests, 2 runs) | 292.4 s concurrent · 287.7 s serial · 12/12 | 289.3 s concurrent · 285.9 s serial · 12/12 | parity both prefill-bound without compiled kernels |
| P6 · Single 110k request, decode | 22.77 tok/s | 22.61 tok/s | no regression +0.7% at 110k, +4.6% at 20k |
P6's first measurement (3 runs, 256 decode tokens each, not interleaved) showed the fork's decode 7.1% slower, the same with every fork setting off. It did not reproduce. A re-measurement with 1,024 decode tokens and three interleaved sessions per tree, MTP on, gives the medians in the table, and a per-verify-cycle cost of +0.9% (standard error 1.4%) at 110k after controlling for drafted tokens. With MTP off both trees decode at 16.8–17.0 tok/s at 110k and 24.9–25.4 at 20k. The first gap came from MTP acceptance in a short sample (1.90 against 2.01 tokens per cycle) plus drift between runs that were not interleaved, so it was closed as noise without a bisect.
Re-validation with the prefill fix: fork 4aa66150 against main 013bc3f3
The runs above predate the restored-prefix prefill fix, which changes how a resumed park or a re-issued request continues over restored TurboQuant blocks. So the forced-pressure scenarios and P1/P2 ran again on the fork with the fix (4aa66150), against main at 013bc3f3, with the same venv, ceilings, prompts and settings. Every resume was checked in the log for the fix's dequantized … restored TurboQuant layers line, or its Kept … quantized fallback, and for the reservation that follows it. One run per scenario.
| Scenario | Ceiling | Fork + fix | main 013bc3f3 | Fix in the log |
|---|---|---|---|---|
| Text decode | 44 GB | 3/3 0 aborts; 2 prefill parks, both resumed | 0/3 abort-all mid-prefill after 22 s | not reached: the parks resumed from 0 committed tokens |
| Image decode | 44 / 42 GB | 3/3 0 aborts at both; 1 and 2 decode pauses, all resumed | 0/3 two abort-alls, one admission rejection (44 GB) | not reached: a decode pause resumes its exact cache, with no suffix prefill |
| Image prefill | 44 / 42 GB | 3/3 0 aborts at both; at 42 GB one prefill park, resumed solo; wall 640 s (2,132 s before the fix) | not re-run (no scheduler or TurboQuant change since c0b1056b) | 7 dequantize lines for the 2,048-token image block restored by each prompt, including the resumed park; reservations across 16 caches |
| Never fits | 42 GB | 400 in 0.11 s, all 7 fields; capacity endpoint 200; header 288,900 | not re-run | - |
| Packed prefill, MTP off | 44 / 42 GB | 3/3 0 aborts; packed, but no park at either ceiling | - | not reached (no restore) |
- Image prefill is where the fix shows. Each image prompt restores a 2,048-token block from the paged cache. Before the fix, that small quantized prefix kept the whole 100k–130k prefill on the quantized path: TTFT 1,797 / 1,267 / 2,130 s with two parks. With the fix: 249 / 460 / 637 s. The dense layers are now in the prefill reservation, so admission runs the prompts one after another. At 42 GB a prompt admitted beside another, both dense, was parked under hard pressure 4.6 s later, resumed alone, dequantized again and completed, with a 37 GB peak. That is a park and resume across the dense step on hardware, with no abort.
- Re-issue after a cancel (P1, 2 runs per tree). The fork keeps 73,728 tokens and re-issues in 80.0 and 82.0 s.
mainkeeps nothing and re-issues in 192.0 and 193.0 s, the same as its cold prefill. Before the fix the same fork code took 198.9 s. The log showsdequantized 15 restored TurboQuant layers (73728 tokens) for a 36302-token suffix prefill in 0.42s, then a reservation across 16 caches (+2.22 GB). - Two concurrent 130k prompts (P2). 2/2, 0 aborts, peak 53 GB, wall 574 s, against 51 GB and 564 s before the fix. No regression; no restore is involved.
- Identity after a pause, MTP off. 12 forced pauses per run, all resumed exactly. Two requests are token-identical; the third departs at a 0.125-nat near-tie. Its drift starts at token 4, before its first pause, after a one-off prefill throttle. The commit before the fix gives the same tokens in the same session (logprob-identical, 2,000/2,000), so neither the fix nor the pause path causes it.
- Packed prefill. Output with pressure equals the no-pressure control (the 42 GB run differs from it at one exact tie), and both depart from the alone references at the same tokens, as before. This session never parked a packed row, so that path was not re-exercised.
Upstream defect: slow suffix prefill after a restored TurboQuant prefix
Field illustration of concurrent prefill from the earlier fork.5 recordings, not a recording of the restored-prefix fix or its paired benchmark below.
Tracing P1's missing TTFT gain on c0b1056b led to a second upstream TurboQuant defect. After a paged-SSD prefix hit, the uncached suffix of a prompt prefilled about 2.4× slower per token than a cold prefill of the same positions. It reproduces on main, so it is not a fork bug, and with TurboQuant off the two take the same time.
Mechanism
- A paged-SSD hit restores TurboQuant layers as quantized
TurboQuantKVCacheobjects (#1842), and the scheduler keeps them quantized while it prefills the suffix. - Every chunk of every TurboQuant layer then attends to the whole quantized prefix through the tiled quantized-attention path, never dense SDPA, so the cost grows with the prefix length.
- The prefill capacity reservation reports
across 1 cachesafter such a restore instead of 16, because only the unquantized last layer is a plainKVCacheit can plan. That line is a symptom, not the cause.
Fix
Before a suffix that needs more than one prefill chunk, the scheduler copies the restored TurboQuant layers into dense KVCache layers once, so suffix chunks run the same dense path as a cold prefill. At the end of prefill only the suffix is quantized and appended to the original TurboQuant cache, so the restored prefix codes are reused bit for bit instead of being quantized again. The step runs only when the dense layers, sized for the whole prompt, fit under the prefill peak target, the same gate as the prefill reservation; otherwise it logs that it kept the quantized prefix. A one-chunk suffix, such as a chat turn, keeps the current path. Two commits with 16 tests, on main 013bc3f3.
Results
Qwen3.8-27B, TurboQuant 4-bit KV on 15 of 16 KV layers, one server at a time from the pinned venv. A = 73,787 tokens, stored; B = A + 36,358 tokens with a 73,728-token prefix hit; positions [73,728, 110,086) of B compared with a cold prompt from the same session. One run per configuration.
| Run | Restored suffix | Cold, same positions | Ratio | Reservation after the restore |
|---|---|---|---|---|
main c0b1056b, MTP off | 197.9 s | 82.2 s | 2.41× | across 1 caches (+0.14GB) |
main + fix, MTP off | 87.3 s | 94.7 s | 0.92× | across 16 caches (+2.23GB) |
| Fork + fix, fork settings, MTP on | 84.8 s | 85.0 s | 1.00× | across 16 caches (+2.23GB) |
- TTFT of B: 198.8 s on
main, 87.9 s with the fix. The one-off dense copy of 15 layers × 73,728 tokens takes 0.43 s. - Quality (MTP off, greedy, 512 tokens, per-token logprobs from a local debug hook): cold against cold is identical. Restored output, with or without the fix, departs from cold only at near-ties (a 0.125-nat margin, one bf16 logprob step), at the same tokens, because the quantized prefix itself shifts the distribution. With the fix and without it, the restored outputs agree for the first 239 and 97 tokens, then part at exact ties.
- Memory: the reservation after a restore now plans the dense layers (16 caches, +2.23 GB instead of 1 cache, +0.14 GB), and the memory guards see them as current usage. The prefill peak is 26.29 GB against 25.22 GB for a cold prompt of the same length; the difference is the quantized prefix, about 1 GB, kept until the end of prefill.
- Live smoke on fork.7 (compiled kernels): a 41,323-token prompt with 24,576 tokens restored logged
dequantized 15 restored TurboQuant layers (24576 tokens) for a 16746-token suffix prefill in 0.16sand a reservation across 16 caches. TTFT was 20.6 s, against 53.9 s for the same prompt cold.
main. The quantized fallback never fired, and decode-pause resumes never reach the step, because they restore their exact cache. The fix is proposed as a separate small pull request after PR1, independent of the pause-resume stack.The strategy: pause, don't abort
The most valuable idea of the project was a change of question. When memory reaches the hard limit, the stock server aborts in-flight requests, which throws away every token they prefilled and generated. We asked whether the server could pause a request instead. The paged SSD cache already existed and makes a pause cheap.
The first version, the park, covered only chunked prefill. A forcing benchmark (P7) showed that it works, and also where it stops. The next spike hit a decoding request, which could not be paused, and all three requests were aborted. That led to the full cycle. It pauses and resumes requests in prefill and in decode, image prompts included, and rejects a request that can never fit with a structured error. Below is what the code does, checked against the implementation (omlx/scheduler.py, omlx/process_memory_enforcer.py, omlx/settings.py).
The first version: park a prefill
- Save progress while prefilling (D1,
prefill_progress_commit_blocks). Every N completed blocks (4 blocks of 2048 tokens in the fork app), the prefilled blocks are stored in the paged SSD cache under the request's own block table. This needs the SSD cache. Without it the commit is skipped and a park resumes cold. - Park instead of abort (T7,
memory_pressure_park). At hard pressure (not emergency, and within a budget of 30 deferral polls), the memory enforcer asks the scheduler to park a victim before it considers an abort. The scheduler drops the victim's live KV cache, which frees the memory. It keeps the client stream, prompt, sampling parameters and arrival time. It pins the committed blocks against eviction, both in the in-memory block pool and in the SSD index. The request stays registered and waiting, and the client gets no error. - Resume from cold cache. In the first version, a parked request was re-admitted after
park_resume_ok_polls(default 3) consecutiveokpolls, when the monitor went silent, or when nothing else could be admitted. The re-admission does a normal prefix lookup, which restores the committed blocks, and prefill continues from the last committed boundary. The full cycle replaced this resume rule with a memory-aware one (below).
tests/test_scheduler_park.py plus enforcer tests. Under normal settings it did not fire in our workload:
- In P2 the fork finished with 0 aborts and 0 parks.
- No live log contains a
Parked requestevent. - The four live park attempts (2026-10-02) found only decoding requests and fell back to abort.
Forcing it: the P7 park benchmark
The setup was three concurrent long prompts of about 130k, 115k and 100k tokens, started 0.5 s apart, on an isolated server. It used a low custom memory ceiling (42 GB, hard watermark at 80%), and the pooled-buffer reclaim was disabled so pressure reaches the park decision. We ran it once with text-only prompts and once with an image early in each prompt, on the full fork and on upstream main, one server at a time.
07:04:13 Hard memory pressure: parking a prefill victim on 1 scheduler(s) instead of aborting (poll 1/30) 07:04:13 Parked request e2f2… at 129024 tokens committed (park 1/3), reason: hard memory pressure 07:04:14 Resumed parked request e2f2…: cached=129024 07:04:23 Park skipped for e2f2…: decoding, not in chunked prefill (memory pressure falls back to abort)
| Variant | Parks | Resume | Outcome | Re-issue after abort |
|---|---|---|---|---|
| Fork, text (2 runs) | 1 per run | 0.5 s, all 129,024 committed tokens restored | The next spike hit the resumed request while decoding. It could not be parked, and all 3 aborted. | Warm. Restores of 129,024 tokens logged, 126,976 of 130,029 in a longer run. |
| Fork, image (2 runs) | 0 | n/a | Skipped every time ("multimodal prompt … prefills in the external loop"), then aborted. | Warm. Restores of 129,024 and 98,304 tokens logged (about 98% of the prompt). |
main, text | 0 | n/a (no park path) | 2 requests rejected by the prefill preflight (HTTP 400), then re-issued cold. | Cold (0 cached). |
main, image | 0 | n/a | 3 of 3 aborted, and the cold re-issues aborted too. | None. |
P7 proved the path works: a prefilling request was paused, its memory freed, and it resumed from the SSD cache in about half a second with every committed token restored. It did not save a batch, because decoding rows and image prompts still fell back to abort.
The full cycle
- Single victim (C1). When nothing is pausable, abort one victim (largest live KV, newest on ties) instead of all. The enforcer waits for that memory to drain before it picks another. Emergency and model-unload keep abort-all. Opt-in:
OMLX_MEMORY_ABORT_SINGLE_VICTIM. - Decode pause and resume (C2). The pending sampled token is emitted first. The row's KV is extracted and stored as an exact SSD chain, keyed to the request and pinned, and the row leaves the batch while its stream stays open. The sampler, logits processors, stop-sequence position, parser state and remaining
max_tokensare stashed. Resume restores the chain and re-inserts the row with no prefill, and the RNG is not reseeded. A singleton Lightning MTP row is first handed to standard decode. Batched MTP, DFlash, VLM-MTP and SpecPrefill rows are excluded. - Memory-aware resume (C3). A paused request resumes when estimated free memory covers its need: the KV of its final length plus a transient. It never resumes at hard pressure. FIFO among paused requests, and the head-of-line hold reserves the room.
- Never fits vs fits later (C4). If weights + KV(prompt +
max_tokens) + transient exceed capacity, the request gets HTTP 400context_exceeds_capacitywith seven numeric fields andretryable: false. A request that fits alone waits instead.GET /v1/omlx/capacityand theX-oMLX-Max-Context-Tokensheader expose the numbers. - Harness compaction (C5, client side). The pi extension compacts and retries on
context_exceeds_capacity, and sets its compaction threshold from the capacity endpoint. It has unit tests but no live validation yet. - Image prompts (C6). A text-only suffix after a cached last image is prefilled in the chunked path, so it can be paused. This route is taken only after checking that the suffix's mRoPE positions are
arange + deltaon every plane. Opt-in:OMLX_MEDIA_TEXT_SUFFIX_CHUNKED.
Pause and resume are behind memory_pressure_park (default off). The never-fits gate, the fits-later wait and a progress-based admission-stall clock sit behind memory_scheduling (auto | on | off, default auto). auto turns them on only when the memory guard has an explicit ceiling (the custom tier); otherwise admission stays as on main. The C7 runs all set a custom ceiling, so their evidence holds under the default.
C7: the extended benchmark, and what failed first
C7 extends P7 to decode-phase pressure, image prompts, a never-fits request and greedy identity after a pause. The target is 0 aborts, with one server at a time and the live app quit. The first run (branch e80eb1f7, 42 GB ceiling, hard 0.8, one run per pair) did not meet it:
| Scenario | Branch (first run) | main |
|---|---|---|
| Text decode | 0/3. One prefill park and 2 decode pauses, all resumed (260,112 tokens restored), then 1 single-victim abort and 2 admission rejects | 0/3, abort-all |
| Image decode | 0/3, all aborted at once (abort-all) | 0/3, abort-all |
| Image prefill | 1/3; 2 requests failed after 60 s of admission_paused | 0/3, abort-all |
| Never fits | HTTP 200 (gate inactive), capacity available: false | HTTP 200 |
| Ceiling probes, text decode | 48 GB: 9 pauses and resumes, then 3 aborts. 52 GB: 2 pauses, 3/3 done. 56 GB: no pause, 3/3 done. | n/a |
Every pause logged restored N tokens exactly (stall 1.6–3.5 s), so the mechanics worked. The gaps were in policy and wiring. Each was traced to a root cause in the server logs and fixed test-first, one commit each:
| Gap | Root cause | Fix |
|---|---|---|
| D1 · pauses thrash into aborts | The park hold's "nothing admitted" escape released a paused request on the next step without a fit check, so it paused again until the budget ran out. | Resume line min(admission line − 2%, hard watermark − 5%), anti-thrash rule, solo run with nothing admitted, decode budget 8 (5a881160) |
| D2 · never-fits gate dead on the VLM engine | nn.Module.parameters() skips the adapter's underscore attribute _vlm_model, so the weights summed to 0 and no estimate existed. | Size the weights through the adapter (e5353d70) |
| D3 · abort-all during image prefill | The single-victim selection found no live request, because the external-loop prefill is not in running, and fell back to an abort-all that freed nothing. | External-loop prefill runs solo, no-live-victim aborts nothing below emergency, one decode pause before the emergency abort-all (19aa28ef, 31cab33d) |
| D4 · identity unjudgeable | The references themselves were aborted at 42 GB. | References alone at 56 GB, with headroom (bench only) |
| D5 · third request fails after 60–90 s | The admission-stall clock kept running while other requests made progress (a solo decode, chunk-by-chunk prefill). | Count only time without progress; 600 s bound for deliberate holds (65899460) |
| D6 · pause effect not isolated | Divergences were B=3 vs B=1 batch numerics, some before the first pause. | Test-only OMLX_DEBUG_FORCE_DECODE_PAUSE_AT for B=1 forced pauses (f3c11356) |
| D7 · 46 GB image prefill rejected | With nothing admitted, the current-usage preflight counted memory no admitted request owned: queued vision embeddings plus about 1.9 GB not attributed. | Admit a lone head that fits under the ceiling alone (47c0c70b); wait for a draining store write (3b72cde7, defensive) |
| D8 · MTP-on divergences after pauses | Likely MTP verify regrouping after a pause moving bf16 near-ties (hypothesis; not isolated with MTP on). | Identity re-run with MTP off: tokens identical (bench 95dd47e8) |
Two intermediate runs show the progression:
- After D1–D3 (31cab33d, 48 GB): never-fits passed, image decode finished 3/3 with 0 aborts, and no abort-all fired. But text decode (twice) and image prefill finished 2/3: the third request failed on the admission-stall timeout, which led to D5.
- After D5–D6 (f3c11356): text and image decode finished 3/3. Image prefill at 46 GB still finished 2/3, which led to D7.
Final results
Same model, machine and scenarios, one run per pair. main (b7c7929e) ran the same scenarios at 48 GB. The branch's image-prefill run used 46 GB, which is harsher for the branch.
| Scenario | Branch run | Completed | Aborts | Decode pauses / resumes | Tokens restored | Peak | Max stall | main |
|---|---|---|---|---|---|---|---|---|
| Text decode | f3c11356 · 48 GB | 3/3 | 0 | 6 / 6 | 691,293 | 41 GB | 1.75 s | 0/3 abort-all mid-prefill after 15 s |
| Image decode | f3c11356 · 48 GB | 3/3 | 0 | 5 / 5 | 561,494 | 43 GB | 1.73 s | 0/3 abort-all, 168 s prefill stall |
| Image prefill | 47c0c70b · 46 GB | 3/3 | 0 | 1 / 1 | 132,102 | 42 GB | 1.61 s | 0/3 abort-all, 417 s prefill stall |
Never fits (42 GB, 31cab33d), a 60k-token prompt with max_tokens 2,000,000:
| Branch | main | |
|---|---|---|
| Response | HTTP 400 context_exceeds_capacity in 0.1 s, all 7 fields, retryable: false, no pause or park | HTTP 200 (stream closed in 0.1 s) |
GET /v1/omlx/capacity | 200, available: true | 404 |
X-oMLX-Max-Context-Tokens | 288273 | absent |
Greedy identity after a pause. B=1 at 56 GB, so there is no memory pause, and forced decode pauses at output offsets 100, 323, 1044 and 1706. Each test run is compared with a run without pauses.
| Run | Pauses | Result |
|---|---|---|
| MTP off (47c0c70b; references on 3b72cde7) | 12 | identical Tokens identical for 3/3 requests of 2,000 tokens; logprob drift exactly 0.0. One request's streamed text differs at char 1496. Its tokens are equal, and the reference without pauses shows the same quirk around a stray mid-reasoning <think> token: a pre-existing text-assembly issue, not a pause bug. |
| MTP on (f3c11356) | 12 | near-ties First divergences at tokens 142, 145 and 179: 42–79 tokens after the first pause and before the second, at margins of 0.125–1.0 nat. With MTP off the same prompts are identical. That points to MTP verify regrouping moving near-ties, but it is a hypothesis: identity with MTP off does not by itself rule out a pause-state effect that only appears with MTP on. |
Premises against main, measured at e80eb1f7, before D1–D8:
| Premise | main | Branch | Reading |
|---|---|---|---|
| P1 · Re-issue after mid-prefill cancel | 0 cached · 110,031 re-prefilled | 73,728 cached · 36,303 re-prefilled | PASS |
| P2 · 2×~130k concurrent | 2/2 · 0 aborts · 116 GB · 939 s | 2/2 · 0 aborts · 44 GB · 501 s | PASS |
| P3 · Greedy batch (B=3 vs B=1) | 3/3 degenerate (4-gram 0.33 / 0.29 / 0.18), 121 GB, on b7c7929e with the v0.7.0 app runtime; does not reproduce on 25aebb3e (re-validation) | no degeneration (1.00 / 1.00 / 0.99), 55 GB | no regression |
| P4 · Concurrency (2 runs) | 0.96× · 12/12 ok | 0.98× · 12/12 ok | equal |
| P6 · Single 110k (3 runs, median) | 740.7 tok/s · decode 20.42 | 729.5 (−1.5%) · 20.75 (+1.6%) | within noise |
- Under forced pressure the branch completes every request in the three scenarios with 0 aborts. Requests are paused and resumed in decode, image prompts included, while
mainaborts all three in each. - A pause is exact: with MTP off, the tokens and logprobs after 12 pauses equal those of an uninterrupted run.
- A request that can never fit gets a structured answer in 0.1 s that a harness can act on, instead of running into an abort.
- Limits:
- one Mac, one model family, and one run per pair;
- decode and image decode were measured at f3c11356 and never-fits at 31cab33d, so those scenarios were not re-run after the last admission fixes;
- the branch runs also had the fork's Metal cache limit and batched-decode gate on;
- a pause stalls every row for 1.6–3.5 s while the exact chain is written, because the store is synchronous;
- the premises must be re-run on the final head, because D1–D8 changed admission after e80eb1f7.
Reproduction commands
The harness is benchmarks/unattended/bench_premises.py on the fork branch bench/p7-park (95dd47e8). It overlays a checkout's omlx/ on the app runtime. Run one oMLX server at a time, with every other instance quit. The fork variant sets OMLX_MEMORY_PRESSURE_PARK=true and OMLX_PREFILL_PROGRESS_COMMIT_BLOCKS=4.
# branch build: export once, so every command below gets the same env export OMLX_BENCH_FORK_REPO=<pause-resume checkout> export OMLX_BENCH_EXTRA_ENV=OMLX_DISABLE_PRESSURE_RECLAIM=1,OMLX_MEMORY_ABORT_SINGLE_VICTIM=true,OMLX_MEDIA_TEXT_SUFFIX_CHUNKED=true # references for one scenario: alone, 56 GB python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \ --c7-scenario decode --runs 1 --p7-ceiling-gb 56 --c7-refs-only --out refs/ref-decode # pressure run: decode | image-decode at 48 GB, image-prefill at 46 GB python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \ --c7-scenario decode --runs 1 --p7-ceiling-gb 48 --c7-refs-from refs/ref-decode --out out/branch-decode # never fits python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \ --c7-scenario never-fits --runs 1 --p7-ceiling-gb 42 --out out/branch-never-fits # identity with MTP off: references, then forced pauses python benchmarks/unattended/bench_premises.py --variant fork-nomtp --premise C7 \ --c7-scenario identity --runs 1 --p7-ceiling-gb 56 --c7-refs-only --out out/idrefnm python benchmarks/unattended/bench_premises.py --variant fork-nomtp --premise C7 \ --c7-scenario identity --runs 1 --p7-ceiling-gb 56 --c7-refs-from out/idrefnm \ --c7-force-pause-at auto --out out/idforcenm # main build: re-export, then run the same scenario commands (with main's own --out paths) export OMLX_BENCH_FORK_REPO=<main checkout> export OMLX_BENCH_EXTRA_ENV=OMLX_DISABLE_PRESSURE_RECLAIM=1 # unit and rig tests, then upstream's CI selection (needs pytest-xdist, as CI installs it) pytest tests/test_scheduler_decode_pause.py tests/test_memory_resume_thrash.py \ tests/test_memory_external_prefill.py tests/test_scheduler_capacity_vlm.py \ tests/test_scheduler_forced_decode_pause.py tests/test_scheduler_solo_admission.py pytest tests/ -m "not slow and not integration" -n 3 --dist loadgroup
Our runs also passed OMLX_BATCHED_DECODE_HOLD_MAX_S=120, OMLX_MID_SYSTEM_POLICY=inline_note and OMLX_THINKING_RETENTION_GUARD_TOKENS=20000 to both builds. Those belong to other fork features, and main ignores them. Scenario prompts: about 130k, 115k and 100k tokens, started 0.5 s apart, greedy, max_tokens 2000 (32 for image prefill).
Live production run (fork.5)
The C7 results come from forced pressure on an isolated server. We then ran the packaged fork as the daily server for real unattended coding-agent work: pi with the gentle-shell harness, one orchestrator session and four gentle-shell subagents doing browser-driven documentation capture for a documentation project, plus a second, unrelated agent session. Every request went to the local model through the profile pin.
| Setup | Value |
|---|---|
| Build | oMLX Fork 0.7.0-fork.5: feat/pause-resume-cycle on upstream main commit b7c7929e (#4148, committed 2026-10-05 16:29:00 +0900), running on the official 0.7.0 app runtime (mlx-vlm 0.7.1; upstream main pins 0.7.4). The packaging script's --allow-pin-mismatch option (5dd56ca5) makes that explicit: it builds on the older runtime, warns and records the mismatch in the build summary. |
| Model and machine | Qwen3.8-27B-oQ4e-mtp (TurboQuant 4-bit KV, MTP), M5 Max 128 GB |
| Fork defaults | Shipped as a bundled defaults file, read as a layer below settings.json, so settings.json or the admin UI overrides them without a rebuild (c31820cb, 78291feb). They were previously baked into the app environment. The bundle turns on the prefill progress commit, park and pause, single-victim abort, the cached-media text suffix and memory_scheduling: on. |
| Harness versions | gentle-shell (package gentle-pi 4.0.0) at upstream main commit aa2c0389, "Merge pull request #1835" (feat/background-jobs), committed 2026-10-07 01:30:59 +0200, run from a local checkout with gentle-shell.mjs --link. This main does not contain the #1367 todo prompt-cache fix, which was still open. One other session ran the fix branch fix/todo-prompt-cache-1367 (955523df, 2026-10-05); its main sessions used a remote provider, so only its subagent reached oMLX. |
| Batched-decode gate | Removed from the fork app (159673d5): decode pauses now resolve memory pressure without losing work, so the 200k combined-context cap only held long requests back while memory was free |
| Fork-only packaging | The defaults bundle, the gate removal and --allow-pin-mismatch belong to the fork's app packaging only. They are not part of any upstream pull request. |
| Memory guard | Tier aggressive: ceiling 101.7 GB, soft at 95% and hard at 98% (about 96.6 and 99.7 GB). memory_pressure_park on. |
| Memory scheduling | On for the whole window, from the fork defaults (verified live: capacity endpoint available: true and the X-oMLX-Max-Context-Tokens header present) |
| Window | 2026-10-07 02:31–04:10 local, about 1 h 39 min, 393 samples every 15 s. max_concurrent_requests was raised live through the admin API, with no restart: 3, then 4 at 03:02:00, then 5 at 03:08:15. |
| Limit | Samples | Open requests (avg / max) | Waiting (avg) | Min system free | Cache efficiency |
|---|---|---|---|---|---|
| 3 (02:31–03:02) | 121 | 5.2 / 6 | 2.2 | 42% | 94.5% |
| 4 (03:02–03:08) | 25 | 4.9 / 5 | 0.9 | 30% | 94.8% |
| 5 (03:08–04:10) | 247 | 4.0 / 6 | 0.1 | 20% | 95.4% |
Open requests are those with an open stream, running or queued, and waiting are the queued ones (both from /api/status). System free memory comes from macOS memory_pressure and includes the agents' browsers and other apps. Cache efficiency is the dashboard's cumulative figure at the end of each level.
| Server log, 02:00–04:10 (includes the 31 min before sampling started at 02:31; the per-level table above covers 02:31–04:10) | Count |
|---|---|
| Requests · prompt tokens · served from the prefix cache | 333 · 14.5M · 94.8% |
| Memory-pressure transitions (soft, hard, emergency) | 0 |
| Decode pauses, resumes and parks | 0 |
| Solo admissions, admission waits, never-fits 400s | 0 |
| Memory aborts | 0 |
| Other aborts | 3 at 03:09:01. The orchestrator cancelled 3 subagent tasks, and the server logged stream aborts with no enforcer action. |
| Batched TurboQuant decode | Ragged single-launch path at B=4 (03:04) and B=5 (03:08). No completion hit the token limit: 320 ended on tool calls and 6 on stop. |
| Subagent wave | Sessions | Requests | System prompts per session | Append-only turns | Per-session cache |
|---|---|---|---|---|---|
| 02:28–03:09 | 4 | 92 | 1 | 88 of 88 | 95.5–98.3% |
| From 03:32 (still running) | 4 | 162 by 04:10 | 1 | 158 of 158 | 95.6–98.5% (through 04:14) |
- Concurrency raised live. At a limit of 5 the queue almost emptied (average waiting 2.2 → 0.1), while cache efficiency held at 94.5–95.4%.
- Batched decode at B=4 and B=5. On upstream
mainb7c7929e with the same v0.7.0 app runtime, P3 at B=3 degenerated or aborted. On newermain25aebb3e it completes, but padded batched decode runs at about 1.5 tok/s per row (P3 re-validation). - Cold starts when a wave arrives at once. Each wave's first requests (about 18k tokens, mostly the shared system prompt) arrived together. Of 8, 5 prefilled cold in parallel (58–129 s) and 3 reused 16,384 shared tokens (87–91%). After that, every subagent turn was append-only.
- Upstream prefill throttle. Upstream's adaptive prefill throttle paused chunked prefills 21 times across 14 requests to resize chunks. The highest process memory it logged was 46.6 GB. These are not the fork's pause and resume.
- Memory scheduling stayed on throughout. The log shows "Memory scheduling: off" lines in groups of four or eight (00:27, 03:02, 03:08, 04:19, including times with no settings change). They come from the admin settings page, which builds four throwaway enforcers (one per memory tier, default mode
auto) to preview the tier ceilings; the live enforcer stayed on, which the capacity endpoint and the context header confirm. The misleading lines are fixed in the fork (04bb7899, proposed upstream with slice G): only the live enforcer logs its state, and the admin settings API reports the effective state asmemory_scheduling_effective. The zero solo, admission-wait and never-fits counts therefore reflect a workload that never approached the memory limits, not disabled features.
aggressive tier, pressure starts at 95% of a 101.7 GB ceiling, and this workload stayed far below it. The evidence for the cycle remains the forced-pressure C7 results above. The next step is a live run with an explicit lower ceiling, for example 45 GB on the custom tier.Harness findings from the same sessions
The same sessions also surfaced harness-side prompt changes. They are listed in the gentle-shell section.
Field footage: sustained load
Screen recordings from the same night (about 04:28–04:43, shortly after the measured window), sped up. The oMLX dashboard is shown in its Spanish locale. Terminal and IDE content is pixelated for privacy; the dashboard numbers are as recorded. This was ordinary load, so no pause or resume appears on screen; the pause/resume evidence is the forced-pressure C7 runs above. Use the playback controls to pause, replay or inspect the recordings. The other recordings accompany the use case, batched decode and prefill sections.
What was built
| Change | Problem it addresses | Evidence |
|---|---|---|
| Prefill progress commit (D1) | A cancel or abort mid-prefill threw away everything. Progress is now committed to the paged SSD cache every N blocks, for text and media prompts. | P1 |
| Park instead of abort (T7) | Under memory pressure, a chunked prefill is parked (progress already in the SSD cache) and resumed in arrival order instead of being aborted. See the strategy. | Unit tests; P7 (parked at 129,024 tokens, resumed in 0.5 s) |
| Pause-resume cycle (C1–C6, D1–D8) | Decoding rows and image prompts could not be parked, so pressure still aborted everything. Now one request is paused in prefill or decode, saved as an exact SSD chain, and resumed on the same stream when its memory need fits. Never-fits requests get a structured 400 and a capacity endpoint. | C7: 9/9 completed, 0 aborts (main 0/9); identical tokens after 12 pauses with MTP off |
mlx_cache_limit_gb (T5a) | The Metal buffer cache grew without bound and pushed process memory past the hard watermark. | P2 peak memory |
| Batched-decode context gate + backfill/aging (T5b, T12b) | Two very long rows batched together gain nothing. A held long request no longer blocks short ones, and aging bounds the wait to 120 s. | P4; a limit sweep showed no gain above 200k combined |
| TurboQuant padded-attention fix + ragged kernel (T5d, T5e, T12a, T12c) | Left-padded short rows attended over padding (correctness), and batched decode was about 1.5 tok/s per row (performance). | Norm ratio 0.32 → 1.0; per-layer B=3 39.8 → 1.7 ms; P3, P4 |
inline_note mid-system policy (A1) | A mid-conversation system update after a tool result fell back to strict and was merged into the leading system message, rewriting the prompt head. | RED test: first divergence at the end of the system head |
| Deterministic thinking retention (A2) | A client's synthetic "attached image" user message moved the template's last_query_index and stripped thinking mid-loop, breaking the prefix on every image turn. | 10/10 image turns at 97.4–99.7% hit |
| Observability (A5) | Eviction was silent at INFO, there was no per-request cache-outcome reason, and there was no TTFT breakdown. | Used for every live measurement here |
The cache-reuse investigation
With aborts gone, the cost moved to re-prefill. We attributed every large cache break in the recorded traffic to a cause. The breaks came from the prompt changing in the middle, which invalidates everything after it.
| Cause | Tokens re-prefilled (~1.5 days) | Where it is fixed |
|---|---|---|
The harness rewrote the system prompt (todo block with a per-prompt stale counter in affected parent sessions; recorded tool-section changes also included subagent traffic), and the server's strict fallback folded system updates into the system head | ~1.05M | Harness fix (gentle-shell #1367) + A1 (server) |
Client's synthetic image message moving last_query_index | ~0.94M | A2 (server) |
| Client compactions | ~0.7M | Expected |
| Real user messages (the template strips earlier thinking) | ~0.1M | Kept for quality on the 8-bit model; preserve_thinking: true removes it on the current model (measured) |
The largest cause was in the harness. How it was found, what it cost, the fix and its validation are in the gentle-shell section.
The server-side change A1 is complementary. pi also sends system-prompt changes as mid-conversation system messages, and oMLX's strict fallback merged those back into the head. Any client doing the same benefits from A1.
preserve_thinking: true, measured again
The earlier result. On the 8-bit model with DFlash speculative decoding, keeping all past thinking gave near-perfect reuse, but at about 53k tokens of context the model started emitting literal </think> and raw tool-call XML as text, its draft acceptance fell from 65% to 54%, and the session did not recover. A2 was designed around that result. It keeps thinking only within the current agent loop and strips at real user messages, exactly as the template does with preserve_thinking: false. A deterministic guard strips once when retained loop thinking exceeds N tokens, then stays append-only. The guard is set to 30k.
The question. With false, every user-role message strips the reasoning of the previous segment, so the prompt shrinks and prefill restarts at the first assistant message of that segment. pi sends harness notices (todo updates, subagent results and wakes, background-job notices) as user-role messages, so they trigger the same strip. That was the last cache loss left in the parent session after the gentle-shell fix. true had never been tested for long on the current model (4-bit oQ4e with Lightning MTP), so we measured it on 2026-10-11.
Method. The installed fork.7 app was the only server running. The setting was changed at runtime through the admin API (PUT /admin/api/models/<id>/settings, which applies without a reload and persists), after a backup of the model settings. The client was the installed gentle-shell in pi RPC mode. Each pi request was joined to its server log line by its prompt and cached token counts.
- P1, harness scenario. One parent session with a todo list, a foreground subagent, a background subagent and its wake, a background job and its notice, and two follow-up prompts.
truewas compared against afalserun of the same driver on the same server. - P2, long realistic session. A fixed script of 27 real coding prompts on a scratch clone of the oMLX fork: read and explain code, find call sites, write and run unit tests, fix them, write a log parser with tests, use git. It made 99 tool calls in each run. The
truerun used its 110-minute budget after 27 prompts. Thefalsecontrol then ran the same 27 prompts. We counted degeneration markers in assistant text, tool arguments and reasoning: literal<think>/</think>,<function=,<parameter=,<tool_call>, visible-text repetition loops, length stops and tool-call parse errors. Reasoning repetition-detector signals are reported separately below.
Fixed settings in every run: thinking forced on (budget 3,000 tokens), the 30k retention guard on, MTP on, TurboQuant 4-bit KV, temperature 1.0.
| P2: 27 prompts, same script | true | false |
|---|---|---|
| Context at prompt 1 → prompt 27 | 41.8k → 200.0k | 41.8k → 135.4k |
| User-turn boundaries that reused the whole cached prefix | 26 / 26 | 3 / 26 |
| Uncached tokens per boundary, mean / max | 893 / 1,870 | 4,048 / 15,056 |
| Time to first token at a boundary, mean / max | 6.8 / 15.9 s | 8.5 / 31.3 s |
| Prefix-cache hit rate, all requests | 99.1% | 98.1% |
| Summed prefill time | 690 s | 660 s |
| Output tokens / thinking characters kept in context | 106k / 278k | 66k / 115k |
| MTP acceptance (flat across 40k–200k buckets) | 83.9% | 84.1% |
| Decode tok/s, lowest context bucket → highest | 20.8 → 13.8 | 23.1 → 15.1 |
| Literal markers in text, tool arguments and reasoning; visible-text loops; length stops; parse errors | 0 | 0 |
| Reasoning repetition-detector signals (unclassified) | 3 | 0 |
| Tool errors | 2 | 7 |
| Wall time for the 27 prompts | 6,670 s | 3,773 s |
Primary-record audit. Recounting tool_errors in preserve-thinking/p2-false-turns.jsonl gives 7, correcting the earlier reported 9. The corresponding p2-true-turns.jsonl contains 3 thinking_loops detector signals; the original summary counted only loops in visible text. The detector flags lines of at least 30 characters repeated at least four times within one message. These signals remain unclassified and may include false positives; they are not confirmed degeneration. The local records were audited without rerunning the experiment and are not currently linked as a public archive.
In P1, true made all 11 parent requests full-prefix hits, against 4 block-aligned divergences in 13 with false. The uncached tokens fell from 6,208 to 1,803 and the summed prefill from 17.2 to 8.1 s. The subagent and job sessions behaved the same under both settings.
Findings.
- No confirmed visible degeneration with
trueon this model up to 200k context; reasoning repetition signals remain unclassified, about 4× the point where the 8-bit DFlash setup broke. MTP acceptance stayed flat, and spot checks at 90k, 115k, 121k and 196k quoted code and file:line references from context correctly. The one slip was at 125k: the model ran a command in a documentation path quoted in the harness system prompt, then corrected itself on the next call. This is one run per setting at temperature 1.0, so it is evidence, not proof. - The cache gain is real but small at these segment sizes.
trueremoved every boundary divergence, but each turn here added only 1–15k tokens, so a divergence cost a few seconds. The minutes-long re-prefill seen withfalsein daily use (about 560 s at 135k) needs a turn that reads tens of thousands of tokens of files before the next user message. This script did not produce one. truehas a cost: the context fills about 1.5× faster, because all reasoning stays in it. The model also reasoned about 2.4× more in that run, and decode slows as context grows. The same 27 prompts took 1.8× longer, and summed prefill was about equal, becausetrueprefilled a much longer context. Part of the extra reasoning may be sampling variance.- Confound: the 30k retention guard stayed on in both runs. With
trueit never fired; the prompt never shrank.
Decision. No literal degeneration markers were observed with true in this one run on this model; reasoning repetition-detector signals remain unclassified, and these observations do not establish the absence of quality risk. On 2026-10-11 it was switched on in daily use for further observation on workloads where single turns read large files, accepting the measured context-growth and wall-time tradeoffs. The things to watch are degeneration markers, boundary TTFT, how often sessions reach compaction, and wall time per task. The A2 guard stays at 30k as a backstop.
Live result
- Turns adding a new image: 10 of 10 had a 97.4–99.7% hit, so only the new tool result and image were prefilled.
- Consecutive turns after the client fix: 31 of 31 strictly append-only.
- Session-level cache efficiency on the dashboard: 95.2–95.3%.
gentle-shell: making the harness cache-friendly
This section is for the gentle-shell maintainers. The largest single cache loss in this report was in the harness, not in oMLX. While todos were open, gentle-shell's todo extension rewrote the system prompt on every user prompt. Over about 1.5 days of real use it caused about 1.05M re-prefilled tokens, roughly 80–85 minutes of GPU time, and turns that took 2–6 minutes instead of seconds. We improved the harness with the same method as the server, and the fix is ready on fix/todo-prompt-cache-1367, tracked in #1367.
The harness's existing tests did not expose the cache cost: the todo tool behaved correctly and no request failed, but some agent turns took minutes instead of seconds. We identified the cause by exercising the harness against a local inference server whose cache behaviour we could observe. Prompt-stability tests can catch the rewrite itself; server measurements establish its operational cost.
The process
The symptom
The oMLX dashboard kept showing long prefills, 40k–110k tokens, on agent turns that should have been nearly free, with concurrent requests frozen behind them. Nothing failed and every harness test passed.
Server-side evidence
oMLX's prefix-cache log states where each request diverges from the closest stored sequence. One session diverged at the same position, about token 37.7k, every few turns. A divergence at a fixed position points to something rewriting the prompt there, not to eviction.
Client-side evidence
A small pi extension recorded the exact payload of every request. Diffing consecutive requests found the first changed byte inside the system prompt, on the todo addendum's stale counter, at the same token position the server reported.
Attribution
Doing the same across all recorded sessions attributed about 945k re-prefilled tokens in main sessions and 107k in subagents to system-prompt or tool-section changes, with the todo addendum as the main driver.
Fix, test-first
Todo state moved out of the system prompt into hidden conversation messages, sent only when the list changes. The stale reminder fires once, with no counter, and the state is re-injected once after a compaction. Tests drive the extension through its public hooks and assert that the system prompt stays byte-stable.
Validation on both sides
On the client, the requests that rewrote the system prompt went from 21 of 61 to 0 of 90, and 31 of 31 consecutive turns were append-only. On the server, re-prefills above 20k tokens went from 1–2 per hour to 0.
Re-tests on upstream main
Each re-test drove the harness against the live server. One window on aa2c0389 showed no problem because its trigger never fired, and a later re-test on 43541a0d reproduced it. Our first comment was corrected publicly. The fix was rebased, its 63 todo tests pass, and it passed an independent review.
Subagents and background jobs
A full harness scenario with the fix (todo list, foreground and background subagents, a background job) kept one system prompt per session in the parent and in every child.
The last loss, shared with the server
The remaining parent cache losses came from harness notices. pi sends them as user-role messages, and the chat template strips earlier reasoning at every user-role message when preserve_thinking is off. With it on, every parent request reused its whole prefix.
What it cost on the server
This is about 1.5 days of real use, all on one machine. Figures marked ≈ are derived from measured rates.
| Cost | Without the harness fix | How it is derived |
|---|---|---|
| Tokens re-prefilled | ~1.05M | Divergences attributed to system-prompt or tool-section changes (main sessions plus subagents) |
| GPU time spent re-prefilling | ≈ 80–85 min | 1.05M tokens at the observed average prefill rate of 205–211 tok/s. Deeper contexts are slower, so this is a lower bound. |
| Added wait per affected turn | ≈ 2–6 min | 40k–110k tokens re-prefilled per event at 300–430 tok/s. The rate falls to about 110 tok/s near 170k context. |
| Other requests frozen | up to 155–158 s per event | Image-bearing prompts prefill synchronously, so concurrent decode stops for the whole re-prefill. |
| SSD cache writes | ≈ 90 GB | Re-prefilled blocks are stored again: about 513 blocks × 0.1787 GB per 2048-token block (KV plus GDN sidecar). That churns a 150 GB cache and evicts useful history sooner. |
| Concurrency lost | held requests wait longer | Long re-prefills keep the decode lane busy, so the batched-decode gate holds other sessions for longer. |
On a hosted API, a changed prefix may reduce cached input and increase latency or cost, depending on that provider's cache rules and pricing. No hosted-provider cache or billing experiment was run here; the measured effects are specific to the tested local setup.
How the invalidation was identified
- The symptom on the dashboard. During normal use, the oMLX panel kept showing long prefills (the blue progress bar) on turns that should have been nearly free. One example: 43.2k tokens at about 300 tok/s on an 80k prompt. A concurrent request's decode sat frozen behind it ("last token 46 s ago").
- The server's own diagnosis. The prefix-cache log for that request reads:
prefix cache restore: cached=36864 suffix=43171 re-prefills 43171 of 80035 tokens; closest stored sequence shares the first 37735
The same session diverged again later at 37,739 tokens. A divergence at the same place every few turns points to something rewriting the prompt at a fixed position, not to eviction. - Ruling out the server cache. SSD eviction was checked against the losses: the cache holds about 8 hours of history, and none of the large losses lined up with evictions. Model reloads were hours away. The block grid explains at most 2,047 tokens per event.
- The client-side diff. Comparing the recorded payloads of two consecutive requests from that session showed the first difference inside the system message, at character 70,384 of about 104,700. That is roughly token 37.7k, matching the server's divergence point:
…pending: install dependencies and verify loading. + (stale: 2 turns without an update — bring the list up to date now) </addendum>
- Attribution across sessions. Doing the same across all recorded sessions attributed about 945k re-prefilled tokens in the main sessions and about 107k in subagents to system-prompt or tool-section changes. The todo addendum was the main driver.
- Confirmation. After the fix, the same session's system prompt stopped carrying todo state at 00:26:37. Every later turn was append-only, apart from one expected cache break at the switch.
The fix: keeping the system prompt stable
The gentle-shell harness appended a todo block to the system prompt on every user prompt. Once the list went stale it added a counter, (stale: N turns without an update …), that changed on every prompt. The block sat around token 30–35k of a ~41k system prompt, followed by other sections. In affected parent sessions, every tick or todo edit broke reuse of the rest of the system prompt and the conversation after it. This does not apply to every subagent: on the upstream versions examined in the re-tests, children and background jobs with GENTLE_PI_AGENTS_CHILD=1 do not load the todo hook. System-prompt or tool-section changes recorded in child traffic must therefore not all be attributed to this hook. Single turns lost 70k–110k tokens of cache this way.
We fixed it in the harness. The system prompt is now byte-stable within a session. Todo state travels as a persisted conversation message, sent only when the list actually changes. The stale reminder fires once, with no counter. After a compaction, the state is re-injected once. The fix is tested (45/45 todo tests, with the unrelated failures reproducing on a clean base), was used daily, and is proposed upstream in gentle-shell #1367. The validation below ran on the fix branch fix/todo-prompt-cache-1367 at 955523df (2026-10-05, gentle-shell 4.0.0), not on upstream main.
- Recorded requests carrying a system-prompt mutation: 21 of 61 before, 0 of 90 after.
- Consecutive turns that were strictly append-only after the fix: 31 of 31.
- Server-side re-prefills above 20k tokens: they went from 1–2 per hour to 0.
Re-tests on upstream gentle-shell main
We re-checked upstream gentle-shell main at aa2c0389 during the live production run and posted the results to #1367 on 2026-10-07. Eight subagents sent 344 requests with one system prompt per session; 336 of 336 consecutive pairs were append-only, and per-session prefix-cache hits were 95.5–98.4%. The todo block appeared in no system prompt, and the problem did not recur. For accuracy: aa2c0389 still appends the todo block at before_agent_start, and its trigger (open todos plus a new user prompt) did not occur in this run. The issue stays open for its author and maintainers to decide.
Re-test on 2026-10-10: the problem is still present on upstream main, and the fix had to be applied. We re-tested gentle-shell main at 43541a0d with a test that drives the todo extension's public hooks and with a live pi session against oMLX. Upstream main still appends ## Todo list and its stale counter to the system prompt in before_agent_start (extensions/gentle-todo.ts). In a session with open todos, every new user prompt changed the system prompt: the block appeared, then the counter went from stale: 2 to 3 and 4. Live, the session produced 4 system messages, 3 of them "Updated system prompt section" updates carrying the todo block. Resuming a session also added a todo block (+515 characters) that the opening system prompt never had. The recent reminder change (#1924) adds a request-local reminder message, which is cache-friendly, but the block in the system prompt remains.
The trigger is the main (parent) session. gentle-shell subagents and background jobs do not load the todo hook, which is why the earlier subagent-only window showed no change. Each update is sent as a system message, and the Qwen chat template accepts a system message only at the start, so it is folded into the system head right after the base prompt (about 4,050 tokens). Everything after that point has to be prefilled again. The oMLX log of the live run shows it:
| Request | Upstream main 43541a0d: cached / prompt tokens | With the fix |
|---|---|---|
| 1 (cold) | 0 / 4,090 | 2,048 / 4,091 |
| 2 (tool result) | 4,085 / 4,258 | 4,086 / 4,255 |
| 3 (todo block appears) | 2,048 / 4,351 | 4,086 / 4,342 |
4 (stale: 2) | 4,096 / 4,394 | 4,337 / 4,507 |
5 (stale: 3) | 4,096 / 4,418 | 4,502 / 4,531 |
The session was short, so each break cost only a few hundred tokens here. In a 100k-token session the same break re-prefills almost the whole conversation on every user prompt while todos are open.
The todo list still has to change; the point is where the change goes. The fix does not freeze the todo state. It appends todo updates at the end of the conversation, as hidden messages sent only when the list actually changes, instead of rewriting the system prompt, so the cached prefix is preserved. The stale reminder is sent once, with no counter. We rebased the fix onto 43541a0d (it keeps #1924's reminder). With it, every request reused its full prefix except the expected re-render of the previous answer, and the 63 todo tests pass. It also passed an independent review with no blocking findings. The re-test, the cache table and the rebased fix (branch fix/todo-prompt-cache-1367) were posted to #1367 on 2026-10-10, correcting the 2026-10-07 comment above.
Status on 2026-10-11. Upstream main at d42264b4 still appends the todo block to the system prompt in before_agent_start (appendSystemPromptOnce in extensions/gentle-todo.ts). The fix branch is one commit on 43541a0d. The same patch applies unchanged to 563fede6, where it runs in daily use. It will be rebased onto current main before a pull request is opened.
Subagents and background jobs
On upstream main, the trigger is the parent session: subagents and background jobs run with GENTLE_PI_AGENTS_CHILD=1 and do not load the todo hook. To check the whole harness with the fix, we drove a gentle-shell build that carries the same patch (identical git patch-id to 4b929257, applied on upstream 563fede6) in pi RPC mode against the live server. One parent session ran a todo list, a foreground subagent, a background subagent and its wake, a background job and its notice, and two follow-up prompts.
- The parent kept one system message, the one at session start. Todo snapshots (4) and the subagent and job notices travelled as custom conversation messages.
- Each child session kept one system message.
- Of 18 requests, 11 reused the whole cached prefix and 2 were the cold starts of the parent and the first child. The second child started from a 2048-token block boundary of the first child's cache, as expected. The other 4 were parent requests that diverged at a block boundary; none of them was caused by a system-prompt change.
Harness notices and thinking retention
Those 4 parent divergences happened right after harness notices: a todo update, a subagent result or wake, a job notice. pi sends these as user-role messages, so the Qwen template treats each one as a new user query. With preserve_thinking: false it then strips the reasoning of the previous segment, the prompt shrinks, and prefill restarts at that segment's first assistant message. On the server side, preserve_thinking: true removes the loss: the same scenario had 11 of 11 parent requests reuse their whole prefix, and the uncached tokens fell from 6,208 to 1,803 (measurement). For harness authors this is worth knowing. A notice that arrives as a user-role message is a cache boundary for templates that strip reasoning at the last user query, so how often notices are sent matters.
Other harness findings
Running the harness and the server together during the live production run (fork.5) surfaced more prompt changes that neither side shows on its own. They belong to gentle-shell, not oMLX. The first two are already raised in #1367: the YOLO directive as a follow-up question (related to #1029, volatile RDD status in the system prompt), and the memory addendum toggle in the 2026-10-07 comment. The last two are not reported yet. On aa2c0389 the todo block is still injected into the system prompt at before_agent_start; it did not fire in this window because no new user prompt arrived while todos were open.
- Turning on gentle-shell's YOLO mode injects its directive into the system prompt. In the orchestrator session (2026-10-06 22:53, before this window) that cost one re-prefill of 34,379 tokens: 111.9 s of prefill, against about 1.6 s for the next cache-hit turn.
- An Engram memory addendum was added to and removed from the second session's system prompt twice, and toggled once in the orchestrator's. Each toggle invalidates the cache from that point on.
- The
gentle-ai-workerrole lacked the nativeagent_browsertool, while the harness guard requires it for browser work. - Squad workers launched by a third-party extension did not use the profile's pinned local model. They started on a cloud model and failed for lack of credentials.
Why it matters for harness projects such as gentle-shell
- Prefix and KV caching is how both local servers (oMLX, llama.cpp, vLLM, MLX) and hosted APIs keep long agent sessions affordable. A harness that rewrites an earlier prompt prefix can increase uncached input and latency. The impact depends on each backend's caching rules, pricing and workload.
- Ordinary todo-behaviour tests missed this interaction. A prompt-stability invariant can catch the rewrite without a server; end-to-end measurements are needed to quantify its cache and latency impact.
- The same loop separated the causes cleanly: one was in the harness (fixed on a gentle-shell branch; still present on upstream
mainon 2026-10-10 and 2026-10-11), two were in the server (A1, A2), and the rest were expected (compaction, real user turns).
A proposal for gentle-shell
- A prompt-stability invariant test. Drive N turns through the harness with fakes, and assert that each rendered request is a strict prefix-extension of the previous one, except at declared boundaries (a real user message, compaction). The #1367 fix already includes the narrow version of this for the todo tool. It could be generalized to every extension that touches the system prompt (RDD status, YOLO directive, memory).
- An optional end-to-end check against a local server. Run a scripted session against a local OpenAI-compatible server that reports cached tokens (oMLX does, through
usage.prompt_tokens_details.cached_tokens). Flag any turn whose cached-token count drops below the previous prompt length. - A recorder mode. An opt-in payload recorder makes this kind of diagnosis reproducible for users who report "it got slow".
Open findings
Decode fairness does not cover multimodal prompts
Any request with an image anywhere in its history carries vlm_inputs_embeds and prefills in the synchronous external loop. That happens even when every image is already inside the restored cached prefix and the uncached suffix is pure text. Both chunked branches require vlm_embeds is None, so concurrent decode freezes for the whole prefill: 25–46 s observed, and 155 s on cold 43k prefills. The fix is now built as part of the pause-resume cycle (C6, opt-in OMLX_MEDIA_TEXT_SUFFIX_CHUNKED). It routes a text-only suffix that follows a cached last image through the chunked path, after checking that the suffix positions are t + rope_delta on every mRoPE plane. Tiny-model tests show identical output with the route on and off. Its effect on the decode freezes has not been measured on hardware yet.
TurboQuant decode attention at depth
A regression over 975 single-row requests gives about 250–295 ns per context token per MTP cycle, against roughly 32 ns for a pure KV read at this model's KV size. The intercept is 70–75 ms against a weight roofline near 30 ms. These are estimates from logs. We have not yet done kernel-level profiling.
MTP depth-1 verify uses stock qmm
Depth-1 verify (M=2) runs in 97.5% of cycles, and no verify-qmm route accepts M=2: the routes cover 3–6, 4–8 and 7–24. We estimate 10–20 ms per cycle from this. MTP is still net positive in every context bucket (1.3–1.45×).
How it was validated
Evidence status. The benchmark figures, traffic counts, test-suite outcomes and pilot completion in this report are reported by the author, not an independent reproduction. A reference audit checked published link destinations, internal anchors, available code and tests, arithmetic, and the measured-to-published commit mapping. Link availability or test-source existence does not prove a run passed. This page does not currently link an immutable archive of the primary run logs, result files and exact configuration for each measurement, or an independent wiki-content audit. PR1's non-identical outputs and PR2's unexercised hardware fallback remain limitations; branch links can move, so use the commit hashes for fixed evidence.
- Test-first. Each behaviour came with a regression test that failed before the change and passed after. The fork's full suite has 17,063 passed and 439 skipped, plus three environment-only failures (a local asset model without a chat template).
- Independent reviews. Each slice was reviewed separately, with four review lenses on the high-risk slices. Findings fed back as small follow-up commits, for example graceful degradation when a VLM tokenizer has no
encode. - Benchmarks against upstream. The premise harness ran upstream and fork servers in isolation, and checked that the live app was idle. It flagged contaminated runs, discarded them and reran them.
- Recorded-traffic replay. A client-side recorder captures exact provider payloads, and a replayer re-sends them in order to an isolated server to measure reuse per build.
Source code and commit references
Every commit and branch named in this report links to the fork on GitHub, e-Evolution/omlx (gentle-shell references link to e-Evolution/gentle-shell). Upstream commits are reachable from the fork with the same hashes. The PR branches are fix/turboquant-padded-batched-decode (PR1) and fix/turboquant-restored-prefix-prefill (PR2).
The fork branches were published on 2026-10-11. Before publishing, local machine paths were replaced in benchmark defaults, one test fixture and one docstring (output directory, cache volume name). Comparison of all 17 measured-to-published Git tree pairs found no differences under omlx/. The differences are confined to benchmark path defaults, supporting documentation and examples, the path fixture and the C7 docstring reference. The complete trees are therefore not byte-identical; reproduction requires explicit local path settings. This corroborates source continuity, not the historical measurements themselves. The commit hashes changed. The report shows the published hashes; the table maps each to the hash that was measured, which also appears in the logs. Hovering a published hash shows the same mapping. The earlier PR1 commit 61bd8a06, replaced by the rebased branch, is kept under the tag report/61bd8a06.
| Published commit | Measured as |
|---|---|
65899460 | 082a870c |
5dd56ca5 | 0b932a39 |
19aa28ef | 25685e36 |
159673d5 | 301bde14 |
4aa66150 | 4e81e443 |
f3c11356 | 85c1f902 |
95dd47e8 | 8aa178c5 |
04bb7899 | aa06f32e |
c31820cb | b000a28f |
5a881160 | c36b92fb |
78291feb | c9694977 |
3b72cde7 | d34413ec |
6fbb3274 | e24198f5 |
47c0c70b | e80a1842 |
e5353d70 | f6c601b0 |
31cab33d | f7dd2bc8 |
e80eb1f7 | f92fbd7c |
main (b7c7929e) with a single docs conflict. Upstream's CI selection (pytest tests/ -m "not slow and not integration", Python 3.11) gives 16,941 passed on main and 17,635 passed on the rebased fork, with the same 3 environment-only failures on both. Benchmarks ran each build alone through the same runtime, which pins the v0.7.0 app's dependencies rather than main's. The exception is PR1's P3 re-validation on 25aebb3e, which ran both trees from one venv with main's pins (mlx 0.32.3, mlx-vlm 0.7.7) and no compiled custom kernels. The pause-resume branch (47c0c70b) gives 17,908 passed with the same 3 environment-only failures. With xdist, as CI runs it, the fork rebased on c0b1056b (4aa66150) gives 18,072 passed against main's 16,921, with the same 3 environment-only failures plus one load-sensitive cluster test that passes alone; the c0b1056b re-validation ran both trees from one venv with main's pins. The branches on 013bc3f3 are listed in Proposed contribution.Proposed contribution
To oMLX
We would like to send these upstream as small pull requests, each with its own regression tests and before/after numbers on the same hardware, following docs/CONTRIBUTING.md. Upstream routes feature proposals to GitHub Discussions > Ideas, and blank issues are disabled, so the proposal is posted as Ideas discussion #4440 (2026-10-11). The two TurboQuant fixes, PR1 and PR2, are bug fixes under 1,000 changed lines, so they were opened directly on the same day: PR1 as #4441 and PR2 as #4442. On 2026-10-12 the maintainer merged PR2 as #4442, with a follow-up for the dense dtype when no KV layer is plain and the embedding is quantized, and closed PR1 in favor of #4453, which extends the #4408 kernel to take each row's left padding and lists us as co-author. The pause-resume cycle is over 1,000 changed lines and adds a subsystem, so it was first posted for feedback on #4440; on 2026-10-12 its stack was opened as twelve pull requests, #4478 (ready for review) to #4489 (drafts). Each slice states its dependency and merge order. The order is a proposal and we will adapt it to your preferences.
Every branch below is rebased on upstream main 013bc3f3 (2026-10-10, MLX 0.32.3, mlx-vlm 0.7.7). On that base, CI's command (-m "not slow and not integration" -n 3 --dist loadgroup, Python 3.11, a venv with main's pins) gives 16,928 passed on main, 16,956 with PR1 (+28 tests) and 16,944 with PR2 (+16), with the same 3 environment-only failures (test_gemma4_rendering.py). Before opening them, PR1 and PR2 were also checked against the newer main e02ae514 (2026-10-11): both merge without conflicts, and CI's command gives 16,938 passed on main, 16,966 with PR1 and 16,954 with PR2, with the same 3 failures. The whole stack was rebuilt on the same base from the fork, every slice from P-a to S9, with the memory_scheduling gate (G) before S4; each slice applies on the one before, and its focused tests and CI's command pass at its tip (two load-sensitive tests that pass alone aside). For the pull requests, the stack was rebased on main f3ad34d2 (2026-10-12), and following upstream #4396 its two environment-only switches became settings (memory.abort_single_victim, scheduler.media_text_suffix_chunked) and the forced-pause test knob was dropped. CI's command gives 17,119 passed on that main and 17,175 (P-a) to 17,580 (S9) at the slice tips, with the same 3 failures; one load-sensitive cluster test also failed at S2 and S4 and passes alone 3/3 there.
About two thirds of the cycle's changed lines are tests. For S1–S9 and G, the per-slice counts in the table below add up to 8,632 test lines out of 12,961 (67%); with the prerequisites P-a and P-b, 11,658 of 17,652 (66%). The scheduler is concurrent (the MLX thread, the memory enforcer and the SSD writer interleave), so every edge case found on the real model has a regression test on the real scheduler and enforcer rather than on fakes. That is why it is split into chained pull requests, each carrying its own tests.
| # | Pull request | Lines (tests) | Default | Status |
|---|---|---|---|---|
| PR1 | fix(turboquant): attend each left-padded row's own slice in batched decode and MTP verify. On newer main primarily a performance fix for padded batched decode, with a correctness signal. Two commits: per-row attention for decode, and the per-row path for padded verify (own mask kept across mlx-vlm's per-layer slice). A third commit, an exact rollback of the verify tail, was dropped: upstream #3127 fixed the same lossy rollback on main. | 816 (544): +815 / −1, 28 tests | always (bug fix) | Closed in favor of #4453 (merged 2026-10-12, co-authored). Branch fix/turboquant-padded-batched-decode on main 013bc3f3: 16,956 passed against main's 16,928, the same 3 environment-only failures. Real model, P3 on 25aebb3e with main's pinned dependencies (one run per build; the TurboQuant files are unchanged upstream since then): no aborts or degeneration on either tree; batched decode 5–12× faster per row (1.5 → 8.3 tok/s with MTP on, 0.66 → 8.1 with MTP off), B=3 phase 345 → 66 s and 780 → 65 s, peak 11–15 GB lower; with MTP off, batched rows stay on the B=1 output longer (about token 8–10 against 1–2). Per-layer decode attention runs 9–28× faster (microbenchmark). Historical, before the rebase (main b7c7929e on the v0.7.0 app runtime, PR1 with the dropped rollback commit): the B=3 batch aborted on main (6 aborts, 120 GB peak), and PR1 gave 4-gram 1.00 / 0.99 / 0.99 and 0 aborts. |
| PR2 | fix(scheduler): prefill long suffixes over a dense copy of a restored TurboQuant prefix (details). Two commits: the dense step, and the embedding-dtype fallback when every KV layer is TurboQuant. | 547 (360), 16 tests | always (bug fix) | Merged as #4442 (2026-10-12), independent of PR1 and of the stack. Branch fix/turboquant-restored-prefix-prefill on main 013bc3f3: 16,944 passed, the same 3 environment-only failures. Restored suffix 2.41× → 0.92× of cold on main; quality differences only at near-ties, as without the fix; reservation +2.23 GB. |
| P-a | Prefill progress commit, including the commit executor in its own module (prerequisite) | 2,133 (1,293) | off | Open since 2026-10-12, rebased on main f3ad34d2: P-a #4478, P-b #4479, S1 #4480, S2 #4481, S3 #4482, S5 #4483, G #4484, S4 #4485, S6 #4486, S7 #4487, S8 #4488, S9 #4489. P-a is ready for review; the rest are drafts in merge order. |
| P-b | Prefill park instead of abort, plus the media progress commit and the shutdown watchdog (prerequisite) | 2,558 (1,733) | off | |
| S1 | Single-victim fallback and exact-chain cache primitives | 1,284 (687) | off | |
| S2 | Decode pause and resume (pause primitive, resume fast path, stop and parser continuity, enforcer wiring, MTP handoff) | 2,735 (1,912) | off | |
| S3 | Memory-aware resume priority (resume when the need fits; FIFO hold as the reservation), adapted to #3482 | 532 (348) | off | |
| S5 | Cached-media text suffix through the chunked path (makes image prompts parkable), with the position proof also in the packed prefill path | 997 (758) | off | |
| G | Memory scheduling gate first: the memory_scheduling setting (off without an explicit ceiling), its resolution and propagation by the enforcer, the admin settings and the live state; nothing reads it yet | 696 (464) | adds the setting | |
| S4 | Never-fits gate, fits-later wait and capacity API (endpoint, header, 400 schema; VLM weights sizing), adapted to #3482 and #4252; a parked request's own committed blocks reported as resumed prefill (#4383) | 2,550 (1,612) | memory scheduling | |
| S6 | Resume under the hard watermark without thrashing, solo run, decode pause budget | 922 (537) | off | |
| S7 | Enforcer: spare external-loop prefills, no abort-all without a live victim, emergency pause-first | 1,012 (736) | off / memory scheduling | |
| S8 | Admission progress clock, solo admission, a lone head that fits alone re-measured on a stale reading (the forced-pause test knob of this build was dropped from the opened pull request) | 1,743 (1,238) | memory scheduling / off | |
| S9 | Follow-ups: the hot cache raises the never-fits line, and the stall clock across park holds | 490 (340) | memory scheduling / park |
Line counts are additions plus deletions of each slice against the slice before it, on main 013bc3f3. "Memory scheduling" means off unless an explicit memory ceiling is set or memory_scheduling=on. G adds that gate before any path reads it, and S4, S7 and S8 consult it from their first commit, so with every flag off each prefix of the stack behaves as main on these paths; each gated slice has tests that drive it with the gate off and expect main's behaviour. The C7 evidence on c0b1056b (re-validation) covers P-b, S1–S3, G and S6–S9, the MTP-off identity run covers S2, the never-fits run covers S4, and S5 has unit and tiny-model identity tests only. The rebuild cherry-picked the fork's commits in slice order with mechanical resolutions; the one test that used the fork's defaults file (G's admin-state test) now sets the mode in settings.json, and a test-fixture fix for upstream #4312 moved into S6, whose tests need it. These fork commits are in no pull request: the packaging commits (defaults bundle, gate removal, --allow-pin-mismatch), the defaults-file layer and the shared on/off env reader that goes with it, the benchmarks/unattended harness (linked as reproduction only), and the fork-side test that combines PR2 with the prefill park (it follows once both are on main).
Later, separate oMLX proposals
| Pull request | Size | Notes |
|---|---|---|
| perf(turboquant): ragged single-launch batched kernel | — | Follow-up to PR1. After PR1, batched decode runs at about 8 tok/s per row on newer main (about 7 on the v0.7.0 runtime), against 17–40 tok/s at B=1; the fork's ragged kernel is the next speed step. Upstream's #4453 now runs a padded batch in one kernel launch per layer (maintainer-reported 62–65 tok/s per row with three concurrent requests, Qwen3.6-35B-A3B with TQ4), so this proposal would first be re-measured against it. |
feat(memory): memory.mlx_cache_limit_gb | ~170 | Bounds the Metal buffer cache. Includes i18n entries. |
| feat(cache): eviction summary and per-request cache outcome; TTFT breakdown log | 2 PRs | Observability only. |
feat(api): inline_note as a third mid-system policy | ~700 | Opt-in, next to strict and user_note_safe. |
| feat(api): deterministic thinking retention with a configurable non-query marker list | ~1.6k | We will generalize it beyond one client's marker text first, and check templates one by one. |
| feat(scheduler): batched-decode context gate with backfill and aging | — | On hold. The fork app dropped it in fork.5 (159673d5) because decode pauses now handle the memory case. |
oMLX bug reports we would file (or comment on)
- TurboQuant left-padded batched decode attends over padding columns (short-row norm ratio 0.32 in synthetic tests) and is slow (about 1.5 tok/s per row at B=3 on 25aebb3e), with a reproducer; PR1 is the fix. Degenerate output and aborts were seen only on b7c7929e with the v0.7.0 app runtime. Related: #4110, #3767, #2784.
- Slow suffix prefill after a paged-SSD restore of a TurboQuant prefix (2.4× a cold prefill of the same positions on c0b1056b); PR2 is the fix. Related: #1842, #3933.
- Decode fairness bypassed for multimodal prompts. Related: #3209.
- Lightning MTP single-request output is not bit-reproducible on reruns. It diverged on 1 of 3 runs on upstream.
- A1 and A2 context added to the existing threads (#3608, #1966, #3378, #900, #1003) rather than new issues.
Before each oMLX PR we will
- rebase onto current
main(013bc3f3 at the last rebase, 2026-10-10: MLX 0.32.3, mlx-vlm 0.7.7); - run CI's command:
python -m pytest tests/ -m "not slow and not integration" -n 3 --dist loadgroupon Python 3.11, plus Black and Ruff at 88 columns; - re-run the relevant benchmark against
mainwith run counts.
To gentle-shell
gentle-shell requires an issue with status:approved before a pull request. #1367 is open with status:needs-review, and we have asked for approval so that the fix can go in as a pull request, or be folded into another change if the maintainers prefer.
| Change | Lines (tests) | Status |
|---|---|---|
| fix(todo): deliver todo state as messages to keep the prompt cache-stable. The system prompt stays byte-stable within a session; todo updates are hidden conversation messages sent only when the list changes; the stale reminder fires once; the state is re-injected once after compaction. Keeps #1924's reminder and the blocked/dropped statuses. | 388 (296) | Ready. Branch fix/todo-prompt-cache-1367, one commit (4b929257) on 43541a0d: 63 todo tests, typecheck, check:runtime-modules and verify-package-files pass; the remaining pnpm test failures also fail on a clean base. Waits for status:approved; will be rebased onto current main first. |
| test: a prompt-stability invariant for every extension that touches the system prompt (RDD status, YOLO directive, memory) | — | Proposal (above) |
| An optional end-to-end check against a local server that reports cached tokens, and an opt-in payload recorder | — | Proposal |
The other harness findings: YOLO directive and memory addendum toggles in the system prompt, the worker role without agent_browser, squad workers ignoring the pinned model | — | The first two raised in #1367 (related: #1029); the other two not reported yet, one issue each |

