Skip to content

Switch openCodeMlx default model to Qwen3.8-27B (mlx-vlm) - #54

Open
perNyfelt wants to merge 18 commits into
mainfrom
switch-opencode-mlx-model-qwen3.8-27b
Open

Switch openCodeMlx default model to Qwen3.8-27B (mlx-vlm)#54
perNyfelt wants to merge 18 commits into
mainfrom
switch-opencode-mlx-model-qwen3.8-27b

Conversation

@perNyfelt

@perNyfelt perNyfelt commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

  • Switches demo/openCodeMlx's default MLX_MODEL to mlx-community/Qwen3.8-27B-4bit, a vision-language checkpoint that plain mlx_lm.server can't load (unsupported architecture).
  • Adds detect_model_backend(), which inspects the downloaded model's config.json for a vision_config key to auto-pick mlx_vlm vs mlx_lm as the serving backend — so future MLX_MODEL swaps between text-only and vision-capable checkpoints keep working with the existing "swap one line" UX, no second env var to keep in sync.
  • Adds ensure_mlx_vlm_current() (mirrors ensure_mlx_lm_current's not-installed→hard-error / already-installed→warn-and-continue pattern), only invoked when the detected backend needs it.
  • start_mlx_server_instance() now branches between mlx_lm.server (--prompt-cache-bytes) and python3 -m mlx_vlm.server (--max-kv-size, --trust-remote-code) based on the detected backend. The small background model (Qwen2.5-Coder-1.5B-Instruct-4bit) is explicitly pinned to mlx_lm and unaffected.

Known tradeoff (accepted): mlx-vlm's prompt-prefix caching is limited, so opencode's system prompt may get reprocessed every turn instead of cached like the mlx_lm.server path — a documented upstream opencode+mlx-vlm behavior. This is a real latency cost for the coding-agent workflow, not fixed here.

Also in this PR: LCA model-config consolidation, GPU tuning, and /benchmark

Unrelated to the demo/openCodeMlx work above, but landed on this branch:

  • Consolidates scattered Ollama model config (names, context sizes, custom model recipes) into src/main/bin/lca as the single canonical source. models.sh now derives its model list from lca via a targeted grep+eval instead of duplicating it by hand, and application.properties picks the same values up through LCA_* env vars that lca exports at launch (with literal fallbacks for ./run.sh/mvnw test/IDE runs that bypass lca).
  • Switches the chat/review models from the mlx variant to llama.cpp, raises the review model's context window to 128k, and applies num_gpu/num_batch Modelfile tuning to the main model for full GPU offload on Apple Silicon.
  • Fixes rebuild_custom_model_if_changed() in lca to fingerprint the full desired Modelfile recipe (base model id + context size + extra params) instead of just the base model's id — previously a context- or parameter-only change went undetected, so a stale custom model kept running silently after a config edit.
  • Adds a /benchmark command (ModelRegistry.benchmark(), calling Ollama's /api/generate directly since Spring AI/Embabel doesn't surface Ollama's raw timing fields) to measure real prompt-eval/generation tokens-per-second for a given model — optionally against a real file's contents via --prompt-file, so future model or config changes can be measured instead of guessed at.
  • Fixes two intent-routing bugs surfaced while wiring /benchmark in:
    • Every typed line (even a literal /health) was being routed through the LLM-based intent classifier before dispatch; a command absent from its hand-curated description list could get reinterpreted as something else entirely (observed: /benchmark --model x was misrouted to /run, which then tried to execute benchmark as a literal shell binary). Literal, well-formed slash commands CommandExecutor already recognizes now dispatch directly, skipping the classifier.
    • CommandExecutor's command-parsing regex used \w+, which doesn't match hyphens, so /git-apply was silently truncated to /git and could never dispatch by name; /git-push was additionally never wired into the dispatch switch at all despite having a working ShellCommands.gitPush(...) handler. Both are fixed.

Follow-up fixes from code review

  • demo/openCodeMlx:
    • ensure_mlx_vlm_current() now carries the same transformers>=5.7,<5.13 pin as ensure_mlx_lm_current — both packages share one venv, so an unconstrained pip install mlx-vlm could upgrade transformers past the version mlx-lm's AutoTokenizer.register call tolerates, silently breaking mlx_lm.server (still used unconditionally for the small background model regardless of the main model's backend).
    • MLX_DRAFT_MODEL is no longer downloaded when the main model isn't served via mlx_vlm (speculative decoding is only wired up there); a mismatched quant level between MLX_DRAFT_MODEL and MLX_MODEL is now rejected up front with a clear error instead of silently serving a mismatched pair.
    • The mlx_vlm path's --max-kv-size OOM guard is now overridable via MLX_VLM_MAX_KV_SIZE, with the comment now honestly stating that reusing MLX_CONTEXT_LIMIT as a stand-in is an unverified guess, not a measured bound.
    • cleanup()'s shutdown message now names the actually-detected backend (mlx_vlm.server (main) vs mlx_lm.server (main)) instead of always saying mlx_lm.server.
  • REPL command dispatch: wires /model, /context, /version, /stage, /revert, /commit-suggest, and /applyBlocks into CommandExecutor's dispatch switch and KNOWN_COMMANDS set — these already worked as ShellCommands methods (and were documented/allow-listed) but fell through to "Unknown command" when typed directly, the same class of gap /benchmark hit. While wiring their --confirm/--dry-run/--secret-scan flags, fixed the same Groovy truthiness trap already fixed once for /benchmark's --max-tokens 0: parseBoolean(x) ?: true was silently turning an explicit --flag false back into true.

Second round of review fixes

  • demo/openCodeMlx:
    • _quant_suffix()'s regex used a bare [[ ... ]] && echo ..., so a non-matching model id returned exit status 1; assigned via a bare x=$(_quant_suffix ...) under main()'s set -e, that silently killed the whole script right after the multi-GB model download with no error message. Now always returns 0.
    • _quant_suffix() also now matches DWQ/AWQ-style qualified ids (...-4bit-DWQ, ...-8bit_dwq), which the original ([0-9]+bit)$ missed, letting the draft/main quant-mismatch guard silently no-op for them.
    • Reverted an unrelated, unsubstantiated LOCAL_MODEL_PROMPT rewrite that had been bundled into the drafter-perf commit — it removed exploration-budget/response-length/no-subagents rules with no evidence backing the change, on the exact 4-bit model this project's own history already flags as unable to follow prompt-level fixes reliably. The drafter perf fix itself (disabling speculative decoding by default) is unaffected.
  • REPL command dispatch: closed the last three parseBoolean(x) ?: true / parseInt(x) ?: N truthiness-trap sites in CommandExecutor/review --log-review false, /search --headless false, /run --confirm false (all silently forced back to their default), and /context --padding 0 (silently reset to 2).
  • Testing: added DemoTestScriptsSpec so every demo/test_*.sh now runs as part of ./mvnw test — the _quant_suffix set -e regression above had a passing hand-written test the whole time, but nothing executed it automatically.

Third round of review fixes

  • Demo test determinism: test_updates_mlxvlm.sh, test_updates_mlxlm.sh and test_updates_opencode.sh's "not installed" scenarios all silently depended on a tool's absence from the real system PATH — a property of the machine running them, not of the code under test. Verified by reproducing the exact flip (stubbing python3 to succeed made test_updates_mlxvlm.sh assert 1, get 0). Harmless while nobody ran these by hand; load-bearing now that they're wired into ./mvnw test. Fixed by stubbing absence explicitly (mlx_lm.server/python3) where possible, and by adding a real-environment guard for test_updates_opencode.sh (opencode itself is what's being checked, so its "not installed" scenarios now skip with a clear message if a real opencode is actually resolvable on the test's PATH, rather than silently running a real opencode upgrade).
  • DemoTestScriptsSpec hang risk: process.waitFor() had no timeout, and one of these scripts can reach a real network call on a misconfigured machine. Output is now collected on a background thread concurrently with a bounded waitFor(120s) plus destroyForcibly() on expiry — a timeout on waitFor() alone wouldn't have been enough, since the prior blocking process.inputStream.text read would hang before ever reaching it.
  • DemoTestScriptsSpec discovery scope: pinned to git ls-files demo output so an untracked scratch script can't silently join the build (verified by dropping in an untracked failing script and confirming it's excluded).

Fourth round of review fixes

  • DemoTestScriptsSpec's own git subprocess had the same bug it had just fixed: gitTrackedDemoTestScripts()'s git ls-files call blocked on process.inputStream.text before its bounded waitFor(30s) — a hung git (index lock, credential prompt) would hang there regardless of the timeout. Extracted a shared runBounded() helper (concurrent reader thread + bounded wait + forced kill) used by both process invocations, so there's one place this gets fixed instead of two to keep in sync. Also switched the shared output buffer from StringBuilder to StringBufferreader.join(5000) only gives a happens-before guarantee when the reader actually finishes in time, and on the timeout path it might not have.
  • Silent zero-coverage risk: an empty git-tracked-names result would filter every script out. Verified first that Spock actually already throws "Data provider has no data" on an empty where: list (doesn't silently pass), then added an explicit assertion anyway for a much clearer diagnosis pointing at the tracked-files lookup specifically.
  • The opencode "not installed" skip was invisible to the build: DemoTestScriptsSpec only asserts exit 0 and no FAIL:, and the guard's skip path satisfied both — so on a machine with a real opencode on REAL_PATH, those three scenarios would quietly vanish from coverage while the build stayed green. Narrowed the PATH those scenarios use from /usr/bin:/bin:/usr/local/bin to /usr/bin:/bin (verified mkdir/cat/chmod/bash/sh all still resolve there) — the official opencode installer already targets ~/.opencode/bin, off REAL_PATH entirely, so this converts an environment-dependent skip that could fire on a normal Homebrew setup into one that essentially never fires, while keeping the guard itself as a defense-in-depth fallback.

Test plan

  • bash -n demo/openCodeMlx syntax check
  • demo/test_updates_opencode.sh — 8/8 passing (no regression)
  • demo/test_updates_mlxvlm.sh (new) — 9/9 passing, asserts the shared transformers pin survives both install and update paths
  • demo/test_quant_suffix.sh (new) — 5/5 passing
  • Ran the real script end-to-end on Apple Silicon: full ~27GB ModelScope download succeeded, backend auto-detected as mlx_vlm, mlx-vlm auto-installed
  • Confirmed real mlx_vlm.server --help output matches the flags used in the script
  • Started the actual server against the real downloaded model; /v1/models responds (what the script polls)
  • Sent a real tool-calling chat-completions request and got back a correct tool_calls response, confirming the agentic use case works end-to-end
  • ./mvnw test — full suite green (1180 tests) across all four review rounds, including demo/test_*.sh now running as bounded, deterministic JVM tests via DemoTestScriptsSpec with no unbounded subprocess waits anywhere in that spec
  • Manually verified /benchmark --model qwen3.8-review:latest --prompt-file <real source file> reports sensible tokens/sec against the tuned model

🤖 Generated with Claude Code

…mlx-vlm

Qwen3.8-27B-8bit is a vision-language checkpoint that mlx_lm.server can't
load (unsupported architecture), so serving it requires mlx-vlm's own
OpenAI-compatible server instead. Auto-detect the backend per model from
its downloaded config.json (vision_config presence) so future MLX_MODEL
swaps between text-only and vision-capable checkpoints keep working with
just the one-line change the script already supports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@perNyfelt

Copy link
Copy Markdown
Member Author

Reviewed PR #54

Verified against the actual environment:

  • \ bash -n demo/openCodeMlx — syntax OK
  • Ran all demo test suites: test_updates_opencode 8/8, test_updates_model 10/10, test_updates_mlxlm 8/8, test_main_update 6/6, test_main_guard 4/4 — no regressions
  • Confirmed real python3 -m mlx_vlm.server --help exposes exactly the flags the new branch passes: --model, --host, --port, --prefill-step-size, --max-kv-size, --trust-remote-code
  • Small model path correctly pinned to mlx_lm with --prompt-cache-bytes unchanged

Review notes (non-blocking):

  1. Double opt-in is clumsy: the new default makes the active line MLX_MODEL="${MLX_MODEL:-mlx-community/Qwen3.8-27B-8bit} and the very next line a commented-out #MLX_MODEL="${MLX_MODEL:-...Coder-30B...}". The documented pattern is one active MLX_MODEL=line (the other model lines below are already bare#MLX_...comments). The new Qwen3 line should be the active one and the old default commented out as a plain#MLX_MODEL\u003d...— as written, this file has twoMLX_MODEL=` assignments at lines 13–14.
  2. ensure_mlx_vlm_current pip line: pip install --upgrade pip mlx-vlm — consider matching the pip install ... spelling used two lines above in ensure_mlx_lm_current for consistency (cosmetic).
  3. The name is ensure_mlx_vlm_current but the body mirrors ensure_mlx_lm_current; fine, just noting it's deliberately the same install-or-update shape.
  4. detect_model_backend falling back to mlx_lm on any read/parse failure is a sensible fail-safe for existing text-only models — agreed with the design.
  5. --max-kv-size reuses `` (128k tokens) as the token-count analogue of the byte cap — a reasonable mapping given mlx-vlm exposes no byte-based knob.

Switch to mlx-community/Qwen3.8-27B-4bit paired with the
Qwen3.8-27B-MTP-4bit draft head via mlx_vlm.server's
--draft-model/--draft-kind mtp, since the 8-bit model alone was too
slow for practical use. Verified live: 17.8 tok/s decode (up from
~12 tok/s baseline) with a ~77% draft-token acceptance rate.

Also bind model downloads to a specific network interface
(MLX_DOWNLOAD_INTERFACE, default en7) via a socket.socket subclass
in the huggingface_hub/modelscope download snippets, since
GlobalProtect VPN was found to reset every connection to
huggingface.co outright. Disables hf_xet when binding is active,
since its separate Rust networking stack bypasses the socket patch
and was observed to hang indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@perNyfelt

Copy link
Copy Markdown
Member Author

Follow-up: the 8-bit model was too slow in practice, so this branch now switches to mlx-community/Qwen3.8-27B-4bit with real speculative decoding via the mlx-community/Qwen3.8-27B-MTP-4bit draft head (--draft-model/--draft-kind mtp).

Also fixed model downloads to bind to a specific network interface (MLX_DOWNLOAD_INTERFACE, default en7) — GlobalProtect VPN was resetting every connection to huggingface.co outright.

Live-verified on real hardware:

  • Both models download successfully via en7 (bypassing the VPN block)
  • mlx_vlm.server starts with the draft model attached, /v1/models responds
  • Tool-calling smoke test works correctly with speculative decoding active
  • Decode throughput: 17.8 tok/s (195-token generation, ~77% draft-token acceptance) vs the previous ~12 tok/s 8-bit baseline

perNyfelt and others added 5 commits August 16, 2026 20:09
…default

This is an open-source repo; defaulting to a specific machine's network
interface name doesn't generalize. MLX_DOWNLOAD_INTERFACE now defaults to
empty (default route), and users can opt in with their own interface name
if they hit VPN issues like the GlobalProtect one described in the comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Consolidates scattered Ollama model config (names, context sizes, custom
model recipes) into src/main/bin/lca as the single canonical source, with
models.sh deriving from it and application.properties picking it up via
LCA_* env vars lca exports at launch. Switches the chat/review models from
the mlx variant to llama.cpp, raises the review model's context to 128k,
and applies num_gpu/num_batch tuning to the main model for full GPU
offload on Apple Silicon.

Fixes rebuild_custom_model_if_changed() to fingerprint the full desired
Modelfile recipe (base id + context + extra params), not just the base
model's id — previously a context/parameter-only change went undetected
and a stale custom model silently kept running.

Adds a /benchmark command (ModelRegistry.benchmark() calling Ollama's
/api/generate directly) to measure real tokens/sec for a given model,
optionally against a real file's contents via --prompt-file, so future
model/config changes can be measured instead of guessed at.

Also fixes two intent-routing bugs found while wiring /benchmark in:
literal, well-formed slash commands (e.g. "/benchmark --model x") no
longer risk being misrouted by the LLM intent classifier — they're
dispatched directly when CommandExecutor already recognizes them. And
COMMAND_PATTERN's \w+ was truncating hyphenated command names at the
first hyphen, so /git-apply and the entirely-unwired /git-push could
never actually be dispatched by name; both are fixed and wired up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…k PR

- models.sh's createCustomModel now fingerprints the same base_id|ctx|extra
  signature lca's rebuild_custom_model_if_changed uses, sharing its
  MODEL_STATE_DIR — previously it only checked "does a model with this name
  exist?", so the exact staleness bug fixed on the lca side was still live
  when running models.sh directly (a context/param-only edit silently kept
  serving the old custom model, requiring -f to notice).
- models.sh now asserts every variable it reads from lca via grep+eval
  actually resolved to a non-empty value, failing fast instead of silently
  proceeding with an empty model name/context if the grep alternation and
  lca's variable names ever drift apart.
- /benchmark --max-tokens 0 no longer silently becomes 200 (Groovy
  truthiness treats 0 as falsy under "?: 200"); negative/zero values are
  now rejected with a clear message instead of passing num_predict: -1
  through to Ollama unbounded.
- /benchmark's tokens/sec output now formats with Locale.ROOT so it's
  consistent across JVM locales — the point of the numbers is comparing
  them across config/model changes.
- Added a test guarding the known asymmetric drift risk in
  CommandExecutor's KNOWN_COMMANDS bypass set: every command it claims to
  recognize is asserted to actually dispatch instead of falling through to
  the unknown-command fallback.
- Fixed a stale qwen3.6-128k reference in application-batch-test.properties'
  comment (model was renamed to qwen3.8-192k earlier in this branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…smatch, stale label

- ensure_mlx_vlm_current() now carries the same "transformers>=5.7,<5.13"
  pin as ensure_mlx_lm_current: both share one venv, so an unconstrained
  "pip install mlx-vlm" could upgrade transformers past 5.13 to satisfy its
  own metadata, silently breaking mlx-lm's AutoTokenizer.register call at
  import time - and mlx_lm.server is still used unconditionally for the
  small background model regardless of which backend serves the main model.
- MLX_DRAFT_MODEL is no longer downloaded when the main model isn't served
  via mlx_vlm: --draft-model is only ever passed on that path, so gating on
  MLX_DRAFT_MODEL alone downloaded a multi-GB drafter that start_mlx_server_instance
  would then silently discard on the mlx_lm path. Warns instead when this happens.
- Added a fail-fast quant-suffix check (new _quant_suffix helper) so a
  MLX_DRAFT_MODEL whose quant level doesn't match MLX_MODEL's is rejected
  with a clear error before downloading anything, instead of silently
  serving a mismatched drafter/base-model pair.
- --max-kv-size (the mlx_vlm path's closest analogue to --prompt-cache-bytes)
  is now overridable via MLX_VLM_MAX_KV_SIZE, with the comment now stating
  plainly that reusing MLX_CONTEXT_LIMIT as a token-count stand-in is an
  unverified guess at the real per-token KV footprint, not a measured bound.
- cleanup()'s stop_and_report label now reflects the actually-detected
  backend ("mlx_vlm.server (main)" vs "mlx_lm.server (main)") instead of
  always saying mlx_lm.server, matching the startup message.
- Added test_updates_mlxvlm.sh (mirrors test_updates_mlxlm.sh, asserting
  the transformers pin survives both the install and update paths) and
  test_quant_suffix.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-suggest, /applyBlocks

These seven commands already existed as working ShellCommands methods (and
were listed in help() and assistant.intent.allowed-commands) but had no
case in CommandExecutor's dispatch switch and were absent from
KNOWN_COMMANDS. That's the same structural gap /benchmark hit earlier:
typing one of them verbatim fell through to "Unknown command" instead of
either dispatching directly or reaching the LLM intent classifier for a
command it doesn't describe either - closing part of the coverage risk
flagged in the /benchmark misrouting review (a case added to the switch
without a KNOWN_COMMANDS entry, or vice versa, silently breaks dispatch).

While wiring --confirm/--dry-run/--secret-scan flags for /stage, /revert,
/commit-suggest and /applyBlocks, hit the exact truthiness trap already
fixed once for /benchmark's --max-tokens 0: "parseBoolean(x) ?: true"
silently turns an explicit "--flag false" back into true, since Groovy's
Elvis operator treats a parsed `false` as absent. Added a shared
parseBooleanFlag(value, default) helper using an explicit null check
instead, and used it everywhere a flag's default is `true` (so an explicit
false actually takes effect) in this newly-wired code.

Also fixed a stale test: `isKnownCommand('/model --set foo')` was
asserting false from before /model was wired in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@perNyfelt perNyfelt changed the title Switch openCodeMlx default model to Qwen3.8-27B-8bit (mlx-vlm) Switch openCodeMlx default model to Qwen3.8-27B (mlx-vlm) Aug 28, 2026
perNyfelt and others added 11 commits August 28, 2026 16:29
Benchmarked via mlx_vlm.generate --verbose against MLX_MODEL: the
Qwen3.8-27B-MTP-4bit drafter only achieved ~55% draft-token acceptance,
making it a net loss - 12.5 tok/s generation with the drafter enabled vs
21.3 tok/s without it. MLX_DRAFT_MODEL now defaults to empty (speculative
decoding off) instead of always attempting it.

Also replaces LOCAL_MODEL_PROMPT with a looser variant (still grounds
claims in shown content only, but drops the no-subagents/hard-limits
rules that turned out to be too restrictive for this model). The prior
working tree had two back-to-back assignments to the same variable - the
first was dead (immediately shadowed by the second) and is removed here;
only the surviving, actually-in-effect prompt remains.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main() runs under set -e, and its call sites assign _quant_suffix's
result via bare "main_quant=$(_quant_suffix "$MLX_MODEL")" - not inside
an if/while condition, so the assignment takes the exit status of the
command substitution directly. _quant_suffix's body was
"[[ ... ]] && echo ...", which returns 1 (the [[ ]]'s own status) on a
no-match model id, silently killing the whole script right after the
multi-GB model download - no error message, and no cleanup trap
installed yet (that happens later in main()).

Trigger: mlx_vlm backend + MLX_DRAFT_MODEL set (the default) + an
MLX_MODEL id not ending in "<N>bit" (e.g. an unquantized/bf16 VLM
checkpoint). _quant_suffix now always returns 0, echoing the match (or
nothing) without letting a non-match propagate as a command failure.

Added a set -e regression test to test_quant_suffix.sh (asserts a bare
assignment under set -e still reaches the next line on a non-matching
model id) - the existing tests only checked stdout via $(...), which
never runs under set -e and so never exercised this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
parseBoolean(parsed.confirm) ?: true hits the same Groovy truthiness trap
parseBooleanFlag was introduced to fix two commits ago: parseBoolean
returns Boolean.FALSE for an explicit "--confirm false", and Elvis
treats that false as absent, re-enabling the confirmation prompt the
caller asked to skip. Switched to parseBooleanFlag(parsed.confirm, true).

Found and fixed the identical pre-existing bug in executeGitApply
(--check, --confirm) and executeApply (--dry-run, --confirm) while
looking - same class of defect, same fix already available.

Added dispatch/regression tests for /git-push, /gitapply, and /apply
covering both the happy path and an explicit "false" override of each
previously-broken flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
([0-9]+bit)$ didn't match ids like "...-4bit-DWQ" or "...-8bit_dwq", so
the draft/main quant-mismatch guard silently no-op'd for those - a
mismatched drafter could still be served without the fail-fast check
ever firing. Now allows exactly one optional "-word"/"_word" qualifier
after the bit count.

Deliberately NOT "([0-9]+bit)([-_][A-Za-z]+)*$" (zero-or-more): tested
that against the existing "does not match a mid-string bit token" case
and it wrongly matches "8bit-prefixed-model" as an 8bit id, since a
multi-word non-quant tail ("-prefixed-model") also satisfies a
repeated-group pattern. The tightened single-optional-group version
passes both the DWQ cases and that existing negative case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts the prompt half of dee6966 (no-subagents, exploration-budget,
and response-length rules removed there). That change was bundled into
an otherwise evidence-backed drafter-perf commit with no evidence of
its own beyond "turned out to be too restrictive," and it landed on
exactly the model (4-bit) already flagged in this project's own history
as unable to follow even the surviving grounding rules regardless of
prompt phrasing - not the model to be loosening guardrails on without
data showing it helps. The drafter/perf fix from that commit is
unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ecutor

Same trap fixed in the last two commits, now closed everywhere it was
still reachable from newly-touched or newly-wired code:

- /review --log-review false was silently forced back to true
  (parseBoolean(x) ?: true).
- /search --headless false was silently forced back to true, same
  pattern.
- /run --confirm false was silently forced back to true - the same
  user-visible defect just fixed for /apply, /gitapply and /git-push.
- /context --padding 0 fell back to the default 2: ShellCommands.context
  explicitly allows 0 (requireMin(padding, 0, ...)), but
  "parseInt(x) ?: 2" treats a parsed 0 as absent, the int-flavored
  version of the same bug already fixed for /benchmark's --max-tokens 0.

All four switched to parseBooleanFlag / an explicit null check, with a
dispatch test asserting the explicit "false"/"0" case for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_quant_suffix.sh existed and would have caught the set -e/exit-1
regression fixed a few commits back, but nothing ran it as part of the
build - it only ran when someone remembered to invoke it by hand, which
is exactly how that regression shipped unnoticed.

Discovers every demo/test_*.sh (except the shared test_helpers.sh
library) at test time and asserts each exits 0 with no "FAIL:" lines,
rather than hardcoding a list that would itself go stale the next time
a demo test file is added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bient state

test_updates_mlxvlm.sh, test_updates_mlxlm.sh and test_updates_opencode.sh's
"not installed" scenarios all relied on a tool's absence from a fixed
system PATH being a property of the CI/dev machine, not of the test.
Verified by reproducing the exact flip: stubbing python3 to succeed made
test_updates_mlxvlm.sh's scenario B assert 1, get 0 - the "already
installed -> warn and continue" branch fired instead of the intended
"not installed -> hard error" branch. This was harmless while nobody ran
these scripts as part of a build; wiring them into ./mvnw test
(DemoTestScriptsSpec) made a real machine's local Python/tooling state
load-bearing for whether the build passes.

- test_updates_mlxlm.sh / test_updates_mlxvlm.sh: explicitly stub
  mlx_lm.server / python3 to fail in the "not installed" scenarios,
  instead of relying on the real system binary being absent.
- test_updates_opencode.sh: can't stub "opencode is absent" the same way
  (opencode itself is the thing being checked, not something routed
  through a shared interpreter/pip). Added a guard that checks whether a
  real opencode is resolvable on the test's REAL_PATH before running the
  "not installed" scenarios (A, B, E) and skips them with a clear message
  if so, rather than letting them silently exercise the "already
  installed -> real opencode upgrade" branch - a real network call
  against whatever opencode install actually exists on that machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… to tracked files

process.waitFor() had no timeout, and since one of these scripts
(test_updates_opencode.sh) can reach a real, network-touching "opencode
upgrade" on a machine where opencode is on PATH, an unbounded wait turns
that into a hung ./mvnw test instead of a failure. Note that
process.inputStream.text (the prior way of collecting output) itself
blocks until the stream closes - a timeout on waitFor() alone wouldn't
have helped, since a hung process would never reach that call. Output is
now collected on a background thread concurrently with a bounded
process.waitFor(120s), with destroyForcibly() on expiry.

Also pins script discovery to `git ls-files demo` output, so an untracked
scratch script (e.g. a developer's local demo/test_whatever.sh) can't
silently join the build - falls back to unfiltered disk listing only if
git itself is unavailable. Verified by dropping an untracked
test_scratch_untracked.sh (that would fail loudly if run) into demo/ and
confirming it's excluded from the discovered script list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… just fixed

gitTrackedDemoTestScripts()'s `git ls-files` call did
"String output = process.inputStream.text" (a blocking read to EOF)
followed by a bounded waitFor(30s) - the exact blocking-read-before-
bounded-wait bug the main test-runner path had just been fixed for in
the same commit. A hung git (index lock, credential prompt) would hang
on the read before the timeout was ever reached, making it decorative.
Extracted the concurrent-reader pattern into a shared runBounded()
helper used by both the demo-script runner and the git subprocess, so
there's one place this gets fixed instead of two copies to keep in sync.

Also:
- Switched the shared output buffer from StringBuilder to StringBuffer.
  reader.join(5000) only supplies a happens-before edge when the reader
  thread actually terminates in time; on the timeout/force-kill path it
  may not have, leaving outputBuffer.toString() as an unsynchronized
  read of a StringBuilder that could still be mid-append on another
  thread. StringBuffer's synchronized methods close that gap.
- Added an explicit assertion when the git-tracked-name filter narrows
  discovery to zero scripts, with a message pointing at the tracked-files
  lookup specifically (not "demo/ has no tests"). Verified first that
  Spock actually already throws "Data provider has no data" on an empty
  where: list rather than silently running zero iterations - so the
  silent-green-build failure mode itself doesn't occur, but the assertion
  gives a far clearer diagnosis than that generic message would.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the rarest case

The previous fix made scenarios A/B/E skip (rather than silently
mis-exercise) when a real opencode is resolvable on REAL_PATH - but
DemoTestScriptsSpec only asserts exit 0 and no "FAIL:", and a skip
satisfies both, so on a machine with opencode on REAL_PATH those three
scenarios would quietly disappear from coverage while the build still
reported green.

Narrowed the PATH used by scenarios A/B/E (and the guard's own check)
from REAL_PATH ("/usr/bin:/bin:/usr/local/bin") to
"/usr/bin:/bin" - dropping the one directory (/usr/local/bin) a
system-wide opencode install might realistically symlink into on this
project's target platform. The official installer already puts opencode
in ~/.opencode/bin, off REAL_PATH to begin with; /usr/bin and /bin alone
still cover every coreutil (mkdir/cat/chmod/bash/sh) these scenarios use,
verified by confirming each resolves under /bin on this machine. This
converts the skip from "silently hit on common developer/Homebrew setups"
to "only hit if opencode is symlinked directly into /usr/bin or /bin" -
essentially never, while keeping the guard itself as a defense-in-depth
fallback rather than removing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant