Skip to content

feat(fusion): two slots, either kind — pick who orchestrates and who executes - #403

Open
plombeer31 wants to merge 9 commits into
mainfrom
feat/fusion-any-leg
Open

feat(fusion): two slots, either kind — pick who orchestrates and who executes#403
plombeer31 wants to merge 9 commits into
mainfrom
feat/fusion-any-leg

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

Stacked on #398 (which stacks on #391); the diff to review here is the last commit.

Fusion assumed its pairing — cloud orchestrator, local workers. That is the right default and the economics the mode was built for, but it was baked in as a rule, and it did not have to be. Cheap local planning driving capable cloud executors is a real use case; so is a large local model orchestrating a small one.

So fusion now has two slots and either may hold either kind.

What was actually enforcing the pairing

Three places, none of which needed to:

  • The resolver. The worker leg defaulted to "the first llama-server provider, or nothing". It now still prefers a local provider — that is the default pairing — but a pinned leg is honoured whatever its kind, and the fallback is "any provider that is not the other leg".
  • setMode. It forced the orchestrator onto a cloud provider and refused with no-cloud-provider when there wasn't one. An explicit pin now wins; without one the order is the active provider, then the first usable cloud one. The second leg is picked the same way, and the refusal only fires when there is no second provider at all.
  • The pre-flight. It required one of each. It now counts legs that could actually answer — a keyed cloud row, or a local row with something on disk — and asks for two of them.

no-local-provider became no-second-provider, and its sentence stopped prescribing a kind.

In the composer, the two controls are the two slots

  • Provider control = the orchestrator slot: the cloud rows as before, plus local-llama when a model is on disk.
  • Workers control = the worker slot: the models on disk, plus every cloud provider that is not already orchestrating. Fanning out to the model that is doing the orchestrating buys nothing and doubles the bill, so it is not offered.
  • Picking a local model for the workers also claims the slot for the local leg — otherwise a pin left on a cloud provider would quietly ignore the pick.

One honesty fix that came with it

The ### fusion machine line states llama-server slot counts. Those are a fact about the local daemon, so it now states them only when the local leg is the one running the workers; with cloud workers there is no slot pool to describe and the idle daemon's number would be about the wrong machine.

Verified

npx tsc --noEmit clean. New resolver tests for the swapped pairing (local orchestrator + cloud workers resolves to effective fusion with both legs pinned) and for two cloud providers never landing on the same leg. Every test that encoded the old assumption was rewritten to the new contract rather than deleted — the pre-flight's two blockers, setMode's two refusals, the fusion row's detail column, and the worker rows. src/tui, src/llm, src/prompt, src/tools/fusion, src/config: 4756 passing.

…work

Fusion's split has been advice since the mode shipped, and a capable
cloud model handed a catalog of forty tools reads the twelve files
itself and never fans anything out. A QA run showed exactly that: nine
steps, twelve os.fs.read calls, zero delegations, every completion on
the cloud model.

So the first mutation of a fusion turn is now refused until the turn has
delegated once. Read, plan, split, delegate — then the gate opens, because
two things genuinely belong to the orchestrator afterwards: integration,
which is a write, and anything a worker handed up because it needed
approval, which workers cannot request.

A refusal rather than a hidden descriptor: removing tools mid-turn
rewrites the stable prefix and drops the session's KV cache, while a
refusal costs one tool result and reads as an instruction. Same trade
plan mode makes, and the same one fusion.delegate already makes for a
worker that calls it. Read-only tools, the fan-out itself and the
terminal verbs are never gated — planning is reading, and vetoing reply
would veto the turn's exit.

The guidance is rewritten around the loop it now enforces: plan in the
open with the parts sized, one task per part in one call, briefs that
stand alone, read every reply against its brief, merge yourself, and send
rework back out instead of quietly absorbing it. Same prefix budget as
before (1344 of 1400 bytes).

A worker's own turn is never gated: the flag is per turn and skips
ephemeral ones, so the hands the mode exists to free stay free.
A first live run on a cheap auto-routed model read the files, replied
'no edits were needed', and changed nothing. Stopping after the plan is
the cheapest way to satisfy 'plan in the open', so the line now says the
delegation belongs to the same turn.
The loop built the per-turn fusion context and handed it to the step
executor, which builds the batch context and forwards `isPlanMode` — and
nothing else. The gate was live, unit-tested and never consulted: a
driven run in fusion mode wrote all four files itself with zero refusals
and zero workers.

Forwarded the three fields the same way `isPlanMode` travels, together
so a context can never carry the flag without the ledger behind it.
… fan-out

Nobody should be picking the worker count from a list. It is two
decisions and neither belongs to the operator: how many workers this
machine can serve at once, which the machine knows, and how many a given
job is worth splitting into, which the orchestrator knows once it has
read the job.

The machine half: `localModels.managed.parallel` takes `"auto"` and
defaults to it (config v63). llama.cpp divides `--ctx-size` between
`--parallel` slots, and a slot smaller than a worker's own prompt cannot
serve one — so the count is how many times MIN_SLOT_CONTEXT (16k, the
same figure as MIN_AUTO_CONTEXT) fits in the context the daemon is
launched with, capped at 8, and 1 on a CPU-only launch where concurrent
slots share the same cores. That context is already sized from VRAM by
context-size.ts, so a bigger machine widens the pool with no new probe
and nothing to configure. Resolved in daemon-lifecycle, where the
effective context is known; both launch sites pass the config value
through untouched.

Migration: a pre-v63 file's `parallel: 2` is the schema's old default,
not a choice anyone made, so it reads as `"auto"`. Any other number is
someone's deliberate pin and is kept — as is a 2 written at v63 or later.

The model half: a `fusion.delegate` call that names no `maxWorkers` now
gets the machine's capacity instead of `llm.runMode.fusion.workers`. The
pool notice was reworded to match — it names the slot count as where the
number comes from rather than as a knob to go and raise.

The composer's count rows are gone; its label reads "up to N workers",
because N is the ceiling and not a promise about this turn. The worker
MODEL rows stay: which model runs the workers is a real choice.
`/runmode workers N` survives as the pin.
…executes

Fusion assumed its pairing: cloud orchestrator, local workers. That is
the right default and the economics the mode was built for, but it is
not the only sensible pairing. Cheap local planning driving capable
cloud executors is a real use case, and so is a big local model
orchestrating a small one. The kinds were baked into three places; none
of them had to be.

- The resolver's worker default still prefers a local provider, but only
  as a default — a pinned leg is honoured whatever its kind, and the
  fallback is now "any provider that is not the other leg" rather than
  "a llama-server or nothing".
- setMode no longer forces the orchestrator onto a cloud provider. An
  explicit pin wins; without one the order is the active provider, then
  the first usable cloud one. It picks the second leg the same way and
  refuses only when there is no second provider to pick.
- The pre-flight counts usable legs instead of requiring one of each: a
  keyed cloud row can answer, a local row with something on disk can
  answer, and fusion needs two of those.
- `no-local-provider` becomes `no-second-provider`, and its sentence
  stops prescribing a kind.

In the composer the two controls are now the two slots. The provider
control is the orchestrator: cloud rows as before, plus `local-llama`
when a model is on disk. The workers control is the second slot: the
models on disk, plus every cloud provider that is not already
orchestrating — fanning out to the model doing the orchestrating buys
nothing and doubles the bill. Picking a local model for the workers also
claims the slot for the local leg, so the pick cannot be quietly ignored
by a pin left on a cloud provider.

The `### fusion` machine line only states slot counts when the local leg
is actually running the workers; with cloud workers there is no slot pool
to describe, and the idle daemon's number would be about the wrong
machine.
The schema had the fourth copy of fusion's assumed pairing, and the worst
one: `orchestratorProvider` had to be cloud and `workerProvider` had to
be llama-server, enforced by throwing during config load. With the
composer now able to write the swap, the next launch died before the TUI
existed —

  ConfigValidationError: invalid config:
  llm.runMode.fusion.orchestratorProvider: orchestrator must be a cloud
  provider, "local-llama" is llama-server — edit or delete that file

— which is an operator hand-editing JSON to get their agent back. Found
by driving the shipped binary: pick the swap, quit, relaunch.

The kind check is gone from both legs. What the schema still enforces is
what it is good at: the id names a provider that exists. The one pairing
that is genuinely wrong — both legs on the same provider — belongs to
`resolveRunMode`, which sees both at once and degrades instead of
refusing to start.
…ocal one

Driving the swap on a built binary: pick `local-llama` for the
orchestrator and the strip read `local-llama ⇄ qwen-3.5-4b · up to 2
workers` while the config said the workers were openrouter. Both halves
of the right-hand side were describing the local daemon regardless of
which leg the workers were on — the same class of lie as the chip that
said fusion while the runtime was cloud.

Three places, one cause each:

- `resolveRunMode` filled `workerModel` from `managedModelId`
  unconditionally. That is the model the LOCAL daemon serves, so it now
  only answers for a local worker leg; a cloud leg reports its own
  default chat model.
- The strip's worker half read the local models panel directly. It now
  labels from the worker leg's own provider row, the way the
  orchestrator half already did.
- `up to N workers` is llama-server's request-slot count, which a cloud
  leg does not have. It reads `cloud workers` there instead of quoting a
  number about an idle daemon.
It read `up to 2 workers`, and on a narrow terminal it collided with the
steer hint — `up to 2 work⏎ steer`. The columns bought nothing: the
number is the machine's capacity, not a choice anyone makes, and how
many workers a turn actually spends is the orchestrator's call on that
turn. Both legs are already named by the model segment (`A ⇄ B`).

The worker slot itself stays: it is still one ←/→ away inside the
composer popup, which is where the ←/→ walk has always included it.
Removed the whole prop chain rather than passing null through it —
prompt-shell, prompt-meta-bar, ComposerMetaControls and the selector.
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