Skip to content

tui: stop the ctx dialog writing a context the model cannot load - #6

Merged
androidand merged 9 commits into
devfrom
ctx-dialog-ceiling
Jul 29, 2026
Merged

tui: stop the ctx dialog writing a context the model cannot load#6
androidand merged 9 commits into
devfrom
ctx-dialog-ceiling

Conversation

@androidand

Copy link
Copy Markdown
Owner

Three bugs in the context dialog, verified against the live z4 backend rather than by code reading alone:

$ curl -s http://192.168.1.81:8080/api/fit/instella-moe-16b-a3b-think-f16
{ "configured_ctx": 32768, "max_fit_ctx": 32768, "max_safe_ctx": 26050,
  "kv_mb_at_max_safe_ctx": 1416, "vram_required_mb": 33562, "vram_total_mb": 49136 }

1. Presets above the achievable ceiling were silently selectable

PRESETS in packages/tui/src/local/model-fit.ts includes 98304 ("96k"), unchecked against max_fit_ctx. Log forensics on z4 caught this dialog writing exactly that onto a 32768-trained model.

The dialog now polls getModelFit on its existing 15s timer, annotates over-ceiling rows ⚠ above ceiling (32k) — backend cannot load this, and apply() refuses them with a toast without closing.

DialogSelectOption.disabled was deliberately not used — in this repo's DialogSelect it filters the row out entirely, hiding the reason. (Side finding: that makes the disabled: true info rows in dialog-tuning.tsx invisible today — pre-existing, left alone.)

2. computeRecommendedCtx was uncapped

Clamped only to [65536, 262144], with no knowledge of the ceiling. With the real numbers above it computed (1416 + 15574) × 26050 / 1416 = 312,555 → clamped to 262144 — an 8× over-recommendation on a 32k model.

Now takes an optional maxFitCtx, applied after the existing floor. That ordering is load-bearing: floor-last would leave a 32k model recommended at 64k and make the fix a no-op.

3. A units error made every recommendation systematically wrong

current() read provider.models[id].limit.context, which provider.ts:1529 fills from max_safe_ctx — a prompt budget with output reserve and margin already subtracted — while kvEstMb is the KV for the real hard n_ctx.

The skew is measurable: 26050/32768 = 0.795, a systematic ~20% under-recommendation. This is the mechanism behind the operator report that auto ctx "always sets a very small size", and it is a distinct bug from the over-write in the log.

Now uses configured_ctx from /api/fit, falling back to limit.contextMax (same unit) rather than limit.context.

Server-side guard

local.ts setModelCtxSize now probes getModelFit and returns false when the requested size exceeds max_fit_ctx, so bypassing the TUI does not bypass the check. Unknown ceiling (0) passes through. Kept the endpoint's Schema.Boolean shape rather than widening it into the JS SDK and desktop app; corrected the now-ambiguous TUI toast instead.

Stale hand-copied client

packages/tui/src/local/llama-skein/gen/types.gen.ts is a hand-copied duplicate whose types had no max_fit_ctx at all, so the fix was not expressible against it. Restored byte-identity with the packages/opencode copy. build:llama-skein-client was deliberately not run — it reads a contracts/llama-skein.openapi.json with uncommitted local changes.

Follow-up worth filing: nothing regenerates the TUI copy, so it will drift again on the next spec change.

Verification

  • bun run typecheck → 23 successful, 23 total
  • bunx prettier --check on all authored files → all pass
  • bunx oxlint on touched files → 3 warnings, all pre-existing lines
  • packages/opencode: 41 pass / 0 fail
  • packages/tui: 189 pass / 1 skip / 9 fail — baseline with the work stashed is 181 pass / the identical 9 failures. This adds 8 passes and zero failures.

11 new tests, including a Bun.serve fake llama-skein asserting no PATCH reaches the backend. The guard test was validated by temporarily deleting the guard line and confirming it failed.

androidand and others added 9 commits July 26, 2026 00:48
The loop's only positive termination path was unreachable. loop.ts defined
COMPLETE_SIGNAL and checked output for it, but runIteration sent the user's
prompt verbatim with no wrapper and no system-prompt injection — the model was
never told the safe word existed. A repo-wide search found the literal only in
loop.ts, in two tests that hand-feed it via a mock LLM, and in design docs; the
.skein agent personas emit different tokens entirely (TASK_COMPLETE, ...).

Consequence: no loop could ever reach status "completed". Every run ended
stalled/max_reached/cancelled/error — all failure-shaped — even when the work
finished early.

- new loop/completion.ts (import-free, same cycle-avoidance as similarity.ts):
  contractPart() discloses the token per iteration; matchesCompletion() reads
  it back tolerating case, whitespace and code fences, but only in the trailing
  window, and never when the prompt itself carries the token (an echo is not a
  signal); promptDisablesCompletion() lets create() warn about that up front.
- completionToken is now configurable per loop, defaulting to the existing
  constant, so a loop can align with a persona that signals TASK_COMPLETE.
- the user's prompt stays the first content part and is never rewritten.
- correct the CLI's inaccurate "omit for back-to-back ralph style" interval
  help — DefaultIntervalSeconds applies at wait time regardless.

Also adds the openspec changes covering this and the follow-on work
(session-cancellation-integrity, retire-auto-reply, loop-spec-queue), and
gives fix-loop-reliability the specs/ delta it was missing.

Co-Authored-By: Claude <noreply@anthropic.com>
Four changes, ordered cheapest-and-most-certain first, from an investigation
into whether an "agent swarm" would actually help.

Key measurement (2026-07-26, same instant):
  z4       gpu_util 85%  inference={busy:false, in_flight:0, slots_total:1}
  rocky    gpu_util 99%  inference={busy:true,  in_flight:1, slots_total:1}
  proxmox  gpu_util  3%  inference={busy:false, in_flight:0, slots_total:1}

skein's get_providers_status exposes gpu_util_pct and not the inference block,
so z4 — 48GB free, model resident, zero requests queued — reads as 85% busy and
gets skipped. rocky's 99% happens to agree with reality, which is what makes it
hard to notice. GPU utilisation measures whether silicon is doing something; it
cannot tell "serving a request" from "holding weights". opencode-skein already
reads the exact signal in placement.ts; skein does not.

- provider-capacity-truth: publish queue depth as a provenance-tagged fact
  (exact vs inferred). No new infrastructure. Ship first.
- fleet-instance-presence: instances announce themselves and per-session state
  over the mDNS layer that already exists. Includes wedge detection — the
  18h hang on 2026-07-25 was invisible because every provider signal was green
  (z4 was fine; the client was dead), and nothing watches clients.
- provider-slot-leases: TTL leases + re-verify immediately before dispatch.
  Deliberately advisory — losing the race costs a queued request, not
  correctness, so consensus would be the wrong trade. Corrects an earlier
  assumption: the shared SQLite covers same-host contention exactly but is a
  local file, so it does nothing cross-host.
- agent-coordination-bus: MQTT, gated behind the above, with a decision gate
  that can stop it. Broker exists (hlab-mosquitto 192.168.1.131:1883) but is
  plain-text — 8883/9001 closed — so identifying metadata needs TLS first.
  Carries typed facts only; explicitly not a claim registry (no atomic CAS)
  and not agent chatter.

Co-Authored-By: Claude <noreply@anthropic.com>
…sation

Measured on the live fleet, one instant:

  z4       util= 85%  inference={busy:false, in_flight:0, slots_total:1}
  rocky    util= 99%  inference={busy:true,  in_flight:1, slots_total:1}
  proxmox  util=  3%  inference={busy:false, in_flight:0, slots_total:1}

z4 was completely free — 48GB, model resident, empty queue — while reading 85%
utilised, because an AMD host pinned at ttl 0 never unloads and idle weights
read high. Any scheduler using utilisation as a busy proxy skips it. rocky's 99%
happens to agree with reality, which is what makes the proxy treacherous: it is
right often enough to look sound. Utilisation says whether silicon is doing
something; only the server knows its queue.

Adds local/capacity.ts producing a normalised snapshot with a `signal` field
distinguishing measured queue depth ("exact") from a utilisation guess
("inferred"), so consumers can weight accordingly. Queue depth always wins where
both exist, in both directions.

Also handles two cases that bite:
- unreachable != idle. A failed probe yields no inFlight/freeSlots/busy at all
  rather than zeros, so a scheduler cannot read "not busy" and dispatch into a
  hole.
- exact telemetry without slots_total (host with no model resident, observed on
  m3 and m5) — busy stays authoritative, host reports free pending a swap-in.

Verified live against all five hosts. placement.ts is untouched: same inputs,
same choices; this makes what it already knows observable.

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

Session ses_0691e2d30ffe1mwU1XPH5gr2mQ sat wedged for 18h48m on 2026-07-25 with
no way to stop it. The provider (z4) reported in_flight 0 — it had finished and
moved on — while the client held an ESTABLISHED socket reading a stream that
would never deliver another byte. The log shows 30 cancels inside 30ms, then
silence.

Root cause was ordering. `cancel` used SynchronizedRef.modify, which commits the
new state BEFORE running the returned effect. So the first cancel wrote Idle and
then parked forever inside Fiber.interrupt on an unresponsive fiber:
idleIfCurrent() never ran, onIdle never fired, and the session stayed visibly
busy — while every later cancel matched `case "Idle"` and returned Effect.void.
One real cancel and 29 no-ops. The first Esc disarmed the escape hatch and no
subsequent keypress could recover.

- new Cancelling state: `busy` stays true while a cancel is in flight, so the
  spinner is honest and a second cancel has something to escalate.
- cancel uses modifyEffect, committing Idle only once the run is released.
- interruption is bounded by a grace period (injectable for tests) and racing an
  escalate Deferred, so a second cancel skips the remaining wait.
- release happens regardless of outcome; an abandoned fiber is logged at WARN
  rather than holding the session hostage.

Two things worth knowing for future edits here:

Interruption is signalled on a detached fiber and awaited via a Deferred, not by
racing Fiber.interrupt directly. A race must interrupt its loser and wait for
it, and the loser would be an await on a fiber that by definition will not die —
which reproduces the original hang inside the fix.

ensureRunning treats Cancelling like Idle deliberately: pressing Esc and
immediately submitting a new prompt must not wait for the outgoing fiber to
drain. finishCancel is id-scoped so the in-flight cancellation cannot clobber the
replacement run. This is covered by the pre-existing "cancel does not deadlock
when replacement work starts before interrupted run exits" test.

Adds regression cover including the 30-cancel burst. Runner backs every session,
so the full session/loop/effect suites were run: 30/30 runner, 20/20 loop. The 5
failures in session/llm.test.ts are pre-existing — verified identical with this
change stashed.

Co-Authored-By: Claude <noreply@anthropic.com>
…snapshots

Live incident, 2026-07-26: every opencode-skein process on this machine hung
with zero forward progress and no surfaced error. Root cause: summarize() ran
on every agent step (not once per turn), each call republishing the full
turn diff — patch text included — as a new, permanent event row, since the
session store is event-sourced and never overwrites. One session had 12,881
message.updated rows / 1.6 GB from this alone, out of a 17 GB shared SQLite
file that every concurrent opencode process on the machine contends for.

- busy_timeout=5000 on the shared connection so a lock conflict waits and
  surfaces an error instead of stalling silently.
- Per-session reentrancy guard so overlapping summarize() calls can't stack.
- Throttle the expensive diff+patch-text persist to once per 5s per session,
  even single-threaded; the cheap counter reset now only runs alongside an
  actual full run, so a throttled call no longer flickers the display to zero.
- A force:true call at turn completion guarantees the final persisted diff
  is never left stale by a throttled intermediate step.

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

/models search for a local provider (e.g. "z4", "proxmox") silently dropped
the GB size the moment a filter was typed — dialog-select.tsx's flattened
row footer showed option.category instead of option.footer, clobbering the
size with the provider name rather than showing both.

- Add a `provenance` field so a flattened, filtered row shows size and group
  together (`flattenedFooter()`), instead of one replacing the other.
- Show each local provider's total VRAM/unified memory (fetched once per
  dialog lifetime via /api/hardware, same pattern as the sidebar and
  dialog-model-ctx) folded into that provenance text.
- Add a "Set as default" action alongside Favorite/Set context size, writing
  the selection to the workspace config so it's used as the fallback model
  without a restart.
- Close a latent footgun in mergeDiscoveredModel: an existing entry with
  sizeBytes explicitly undefined would silently clobber a freshly discovered
  size via object-spread precedence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Concurrent opencode-skein agents (manual TUI instances, /loop sessions) in
one checkout switch branches out from under each other — confirmed live:
two opencode-skein processes running against the same brick-now checkout
during the 2026-07-26 incident, with no separation between them.

The external skein orchestrator already avoids this by giving every change
its own worktree at ../opencode-worktrees/<slug> (confirmed via this repo's
own .skein/coder-context.md handoffs). This has been named as a gap twice in
the backlog and punted both times: loop-spec-queue's non-goals call it "a
separate change", provider-slot-leases' non-goals call it "a separate
problem". This is that change.

Phase 1: packages/opencode/src/git/worktree.ts — ensure/merge/cleanup over
plain git worktree add/merge --no-ff/remove, following the existing
shell-out pattern in snapshot/index.ts. Never pushes, matching
loop-spec-queue's authority boundary. Takes repoRoot explicitly so it's
testable standalone against a scratch git repo, independent of loop-spec-
queue's (currently unimplemented) queue driver.

Wiring into /loop's start/stop path is Phase 2, deliberately deferred — see
openspec/changes/agent-worktree-isolation/tasks.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The context dialog offered hand-authored presets up to 1M with nothing
checking them against the model's achievable ceiling, and computed its
recommendation from a prompt budget rather than the hard n_ctx. This is
how `--ctx-size 98304` reached a 32k-trained model.

- fetch /api/fit/{model} in the dialog and annotate every preset above
  max_fit_ctx (rows stay visible; apply() refuses them)
- cap computeRecommendedCtx at max_fit_ctx; the ceiling outranks
  MIN_WORKFLOW_CTX so a 32k model is never told to grow to 64k
- take the current hard ctx from configured_ctx, not limit.context
  (= max_safe_ctx, a prompt budget that already reserves output)
- guard local.model.setCtxSize server-side so a bad value cannot get
  through when the TUI is bypassed
- sync the stale tui copy of the generated llama-skein types, which was
  missing max_fit_ctx and under_configured

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Hey! Your PR title tui: stop the ctx dialog writing a context the model cannot load doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@androidand
androidand merged commit 05fbe44 into dev Jul 29, 2026
3 of 9 checks passed
@androidand
androidand deleted the ctx-dialog-ceiling branch July 29, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant