mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-15 07:39:20 -04:00
Five of the six families whose subscriber is an open HTTP response rather than a process-lifetime cache now travel on pgbus: jobs.<id>.progress, jobs.<id>.result, jobs.<id>.cancel, agent.<name>.events.<user> and responses.<id>.cancel. Both ends of each move together, so there is no state where a publisher is on one carrier and its subscriber on the other. agent.<name>.cancel does NOT move, and the plan was wrong about why. Its only subscriber in the tree is the agent worker, which has no database and so cannot join the PostgreSQL carrier at all. Publishing that cancel on pgbus would have lost every cancel of a worker-run agent while returning nil, which reports a cancel that reached nobody as a cancel that was sent. EventBridge now names its cancel carrier separately, a frontend replica sets it to the carrier the worker reads, and it stays there until a cancel rides the worker's tunnel like every other verb addressed to a worker. The carrier drops at 256 rather than blocking, which is not safe on its own for a result: a lost result has no successor message. It is not the only path. The claiming replica persists the terminal line before it releases the claim, and an open progress stream re-reads the job row once after subscribing and then periodically, so a dropped terminal broadcast costs promptness and never the answer. Both per-request subscriptions close in a defer instead of on one return path, and pgbus grows Subscribers() so the leak they would otherwise cause can be asserted. It has no other symptom: only the first subscriber of a channel issues a LISTEN, so a leaked filter just adds one closure per notification for every stream the replica has ever served. Subscribe now issues its LISTEN before it registers, which makes that count a readiness signal rather than a figure to compare against itself. Two rules that were stated at several sites and pinned at none are now one each. The re-broadcaster is built beside the dispatcher and the bridge and handed to the dispatch loop, so no line is left that can point it at a carrier nobody subscribes to while every spec stays green. The set of statuses a job never leaves is one exported set that the SSE bridge and the store both read. The last hand-written subject filter in production code became messaging.SubjectAgentEventsWildcard. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
81 lines
3.7 KiB
Go
81 lines
3.7 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.
|
|
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),
|
|
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
|
|
}
|