Files
LocalAI/core
Ettore Di Giacinto 1a0f6faea1 fix(distributed): give one tenant's agent tasks a subject of their own
Every AgentJobService built its tasks SyncedMap with the name "agent.tasks",
and there is one service per user. So every tenant published on and subscribed
to the same subject, state.agent-tasks.delta, and SyncedMap.apply scopes
nothing: a task tenant A created was written into tenant B's in-memory map on
every replica, and ListTasks reads that map. Nothing repaired it short of a
process restart.

The subject now carries the tenant in a token of its own,
state.<name>.<tenant>.delta. Four tokens where the unscoped builder makes
three, deliberately: SubjectMatches compares token count before anything else,
so a tenant's subject and the cluster-wide one cannot cross-match, and neither
can two tenants. Putting the tenant inside the name token would not do that,
because the sanitizer folds '.' to '-' and the only filter that could then span
tenants is state.*.delta, which spans every other family too.

The rule is stated once. subscribeFilters calls publishSubject rather than
restating the subject, so a map cannot end up publishing scoped and subscribing
unscoped, which would leak exactly as before while every publish assertion
passed. The one case that decides on its own is the cluster-wide administrative
view: it hydrates from every tenant's rows, so it also takes the per-tenant
wildcard, or it would be stale the moment any tenant wrote. A tenant hydrates
from its own rows and applies only its own deltas. PerTenant defaults to false,
so finetune, quantization and the responses store keep the subject they have.

The second half of the same defect was the delete. taskStoreAdapter.Delete
called DeleteTask(id) and JobStore deleted by primary key with no user
predicate, reachable from DELETE /api/agent/tasks/:id, which takes the id off
the URL. A tenant who learned another tenant's task id destroyed that tenant's
row. The user id now travels with the id and lands as a user_id predicate.
Empty stays the administrative any-owner scope, the same thing an empty id
already means for ListTasks and ListJobs. A foreign delete removes nothing and
returns no error: not yours and not there are the same answer to the caller,
and neither is a store failure.

SetUserID rebuilds the tasks map for the same reason SetTaskSyncNATS does.
GetJobs happens to set the user id first, nothing enforced it, and with the
order reversed the map would be built with an empty tenant and put that user's
tasks back on the cluster-wide subject.

Both halves predate this programme; they are surfaced here rather than caused.
Neither is fully closed for a deployment with the agent pool off, where the
task routes are still served by one cluster-wide service that every
authenticated caller shares; that is a separate gap and it is documented.

testutil.FakeBus grew a real defect this was the first change to trip:
Unsubscribe matched on the filter string, so with two subscribers on one
filter, closing one deafened the other. Subscriptions now carry an id.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-20 03:05:34 +00:00
..
2026-03-30 00:47:27 +02:00