Files
LocalAI/core/application/dispatch_loop_wiring.go
T
Ettore Di Giacinto 3b4858851a feat(distributed): carry an agent cancel on the worker's own tunnel
agent.<name>.cancel was the last family on a message bus, and the only
reason an agent worker dialled one. Its subscriber is the worker running
the execution, and a worker has no database, so the family could not move
to the PostgreSQL fan-out carrier: a cancel published there would reach no
worker while reporting that it had been sent.

It is a control verb now. An agent worker mounts workerctl.PathAgentCancel
on the loopback control plane behind its tunnel and applies the cancel to
the same registry the executor registers a run on. The frontend issues it
through nodes.AgentControlClient.CancelAgentRun.

That call is a FAN-OUT and not a pick, because nothing records which worker
holds a given execution: the claim row names the claiming replica, and it
is deleted when the run ends. Every agent worker a live replica can reach
is asked over its own tunnel, relayed by the peer mesh when a peer holds
it, and each worker answers only for itself.

The answers stay apart, which is why this family was held back. A cancel a
worker made is nil. A cancel some worker could not be asked is
ErrAgentCancelUndelivered, which is neither a refusal nor a missing run. A
cancel every reachable worker declined to own is ErrAgentRunNotOnAnyWorker.
A deployment with no agent worker is ErrNoAgentWorker. Neither new sentinel
wraps ErrWorkerUnroutable and neither is a worker answer, so nothing is
reaped, demoted or evicted because of a cancel.

A worker in the ABSENT CONNECTION condition, one whose tunnel was lost
inside the reconnect grace, counts as undelivered. It is not retried in the
call and not queued: a retry would spend a budget the caller did not
choose, and a queue would need durable state whose only consumer is a run
whose control stream went with the tunnel. A worker whose departure has
outlived the grace is the one routing fact a caller may act on and is
excluded, or a single retired agent node would make every cancel
undelivered for ever.

The fan-out reads a different node set from the pick. A draining worker
takes no new work but is still finishing what it holds, so it is offered
the cancel; a pending one is refused by the tunnel route on every dial and
is not.

With that, nothing in LocalAI connects to NATS. The agent worker's dial,
its credential ladder and its refresh loop are gone, and so is the
frontend's cancel carrier. LOCALAI_NATS_URL is accepted and ignored
everywhere, and distributed mode no longer requires it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-04 05:18:02 +00:00

88 lines
4.2 KiB
Go

// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/jobs"
"github.com/mudler/LocalAI/core/services/nodes"
"gorm.io/gorm"
)
// startJobDispatchLoop builds AND STARTS the loop that takes queued work off the
// job store and drives it on an agent worker.
//
// One function rather than a construction here and a Start somewhere else, and
// that is the point rather than tidiness. A loop that is built and never
// started is a replica that writes claim rows and takes none, so every job in
// the deployment is accepted and none is ever run, and nothing anywhere says
// so. As a separate statement in the start-up path that line's loss has no
// symptom and no spec reaches it: initDistributed opens a database and a bus.
// Fused here, the loop cannot exist without running.
//
// The rest is a named function for the reason newAgentControl is one: two of
// these arguments are silent when they are wrong.
//
// The BROADCASTER is the one worth naming. It is what re-publishes the progress
// and result lines a worker asks for, and it is checked against the allow list
// for that worker's node type. A loop built without one dispatches work
// perfectly well and every SSE stream in the deployment goes quiet: the job
// runs, the answer is persisted, and the user watching it sees nothing until
// they reload. That is a whole feature lost to a nil field, with no error
// anywhere, so it is refused here.
//
// The re-broadcaster is taken already built, from newFanoutBridges, and is a
// *nodes.Rebroadcaster rather than the jobs.ProgressBroadcaster interface the
// loop stores it as. Both of those are deliberate. Taking it built leaves ONE
// expression in the tree that decides which carrier job and agent fan-out goes
// on, next to the dispatcher and the bridge that must read the same one, so
// there is no separate line here to point at a carrier nobody subscribes to:
// that mis-wiring publishes successfully, returns true, reddens no spec in any
// package, and shows up only as an SSE stream with no progress in it. Naming
// the concrete type is what makes the refusal below fire, too: widened to the
// interface, a nil re-broadcaster is a non-nil value holding a nil pointer.
//
// The SELECTOR is built here rather than borrowed from newAgentControl, and
// deliberately: nodes.AgentSelector holds no per-caller state, and sharing one
// would couple the dispatch loop's lifetime to MCP's for nothing.
//
// The reconnect grace it is built with is INERT on this path and is passed
// correctly anyway. The selector reads it only in Reachable, which is what a
// fan-out verb (an agent cancel) asks; this loop asks PickConnected, which
// never needs it, because a worker that is not connected cannot be picked
// whatever the reason. Passing a value this loop cannot observe is still
// cheaper than a second constructor.
func startJobDispatchLoop(ctx context.Context, cfg config.DistributedConfig, db *gorm.DB, store *jobs.JobStore,
registry *nodes.NodeRegistry, conns nodes.AgentConnectionReader,
control *nodes.ControlClient, broadcast *nodes.Rebroadcaster) (*jobs.DispatchLoop, error) {
if cfg.InstanceID == "" {
return nil, fmt.Errorf("the job dispatch loop was built with no instance id: its claims could not be told from ones a dead replica left")
}
if registry == nil || conns == nil {
return nil, fmt.Errorf("the job dispatch loop was built with no way to find a connected agent worker")
}
if broadcast == nil {
return nil, fmt.Errorf("the job dispatch loop was built with no broadcaster: every job would run with its progress and its result reaching no SSE stream in the deployment")
}
loop, err := jobs.NewDispatchLoop(jobs.DispatchConfig{
DB: db,
Owner: cfg.InstanceID,
Selector: nodes.NewAgentSelector(registry, conns, cfg.InstanceID, cfg.WorkerReconnectGrace),
Control: control,
// The allow list lives in nodes and is keyed on the worker's node type;
// nothing here decides what a worker may broadcast on.
Broadcast: broadcast,
Store: store,
})
if err != nil {
return nil, err
}
if err := loop.Start(ctx); err != nil {
return nil, err
}
return loop, nil
}