153 lines
5.4 KiB
Go
153 lines
5.4 KiB
Go
package agentpool
|
|
|
|
// White-box tests (package agentpool) so a spec can build two AgentJobService
|
|
// instances sharing one in-memory bus and assert that agent *tasks* converge
|
|
// across replicas - the bug this migration fixes (ListTasks used to read
|
|
// in-memory only, so a task created on replica A was invisible on replica B).
|
|
// Jobs are deliberately untouched here: they already converge via the dispatcher
|
|
// + DB read-through.
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/syncstate"
|
|
"github.com/mudler/LocalAI/core/services/testutil"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
)
|
|
|
|
// newTaskSyncService builds an AgentJobService wired to the given bus and a
|
|
// throwaway data dir (so the file persister has somewhere to write). Model/config
|
|
// loaders are nil because the task sync paths under test never touch them.
|
|
func newTaskSyncService(bus messaging.MessagingClient) *AgentJobService {
|
|
tmpDir := GinkgoT().TempDir()
|
|
sysState := &system.SystemState{}
|
|
sysState.Model.ModelsPath = tmpDir
|
|
appConfig := config.NewApplicationConfig(
|
|
config.WithDynamicConfigDir(tmpDir),
|
|
config.WithContext(context.Background()),
|
|
)
|
|
appConfig.SystemState = sysState
|
|
|
|
svc := NewAgentJobServiceWithPaths(appConfig, nil, nil, nil,
|
|
// Distinct per-replica files so the file persister write-through never
|
|
// crosses replicas: convergence here must be proven via the bus alone.
|
|
tmpDir+"/tasks.json", tmpDir+"/jobs.json")
|
|
svc.SetTaskSyncNATS(bus)
|
|
return svc
|
|
}
|
|
|
|
var _ = Describe("AgentJobService task cross-replica sync", func() {
|
|
Describe("two replicas sharing one bus", func() {
|
|
var (
|
|
bus *testutil.FakeBus
|
|
a, b *AgentJobService
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
// One shared bus, two replicas: exactly the distributed topology where a
|
|
// round-robin request may land on a replica that did not originate the
|
|
// change.
|
|
bus = testutil.NewFakeBus()
|
|
a = newTaskSyncService(bus)
|
|
b = newTaskSyncService(bus)
|
|
// Start hydrates (empty here) and subscribes both replicas to deltas.
|
|
Expect(a.Start(context.Background())).To(Succeed())
|
|
Expect(b.Start(context.Background())).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
Expect(a.Stop()).To(Succeed())
|
|
Expect(b.Stop()).To(Succeed())
|
|
})
|
|
|
|
It("makes a task created on A visible via B's GetTask and ListTasks", func() {
|
|
id, err := a.CreateTask(schema.Task{Name: "Shared", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
got, err := b.GetTask(id)
|
|
Expect(err).NotTo(HaveOccurred(), "B must see a task A just created")
|
|
Expect(got.Name).To(Equal("Shared"))
|
|
|
|
listed := b.ListTasks()
|
|
Expect(listed).To(HaveLen(1))
|
|
Expect(listed[0].ID).To(Equal(id))
|
|
})
|
|
|
|
It("propagates a task update from A to B", func() {
|
|
id, err := a.CreateTask(schema.Task{Name: "Before", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
Expect(a.UpdateTask(id, schema.Task{Name: "After", Model: "m", Prompt: "p"})).To(Succeed())
|
|
|
|
got, err := b.GetTask(id)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(got.Name).To(Equal("After"), "an update on A must be visible on B")
|
|
})
|
|
|
|
It("removes a task from B when it is deleted on A", func() {
|
|
id, err := a.CreateTask(schema.Task{Name: "Doomed", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = b.GetTask(id)
|
|
Expect(err).NotTo(HaveOccurred(), "precondition: B must have the task before the delete")
|
|
|
|
Expect(a.DeleteTask(id)).To(Succeed())
|
|
|
|
_, err = b.GetTask(id)
|
|
Expect(err).To(HaveOccurred(), "a delete on A must remove the task from B")
|
|
Expect(b.ListTasks()).To(BeEmpty())
|
|
})
|
|
|
|
It("does not re-broadcast a delta it received (echo-loop guard)", func() {
|
|
subject := messaging.SubjectSyncStateDelta("agent.tasks")
|
|
|
|
_, err := a.CreateTask(schema.Task{Name: "Once", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Exactly one publish: A's create. B applies it without re-publishing,
|
|
// otherwise this would be 2+ and a real bus would storm.
|
|
Expect(bus.PublishCount(subject)).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("ListTasks ordering and scoping", func() {
|
|
var svc *AgentJobService
|
|
|
|
BeforeEach(func() {
|
|
svc = newTaskSyncService(testutil.NewFakeBus())
|
|
Expect(svc.Start(context.Background())).To(Succeed())
|
|
})
|
|
AfterEach(func() { Expect(svc.Stop()).To(Succeed()) })
|
|
|
|
It("sorts newest-first, breaking ties by name", func() {
|
|
// CreateTask stamps CreatedAt with time.Now(); space them out so ordering
|
|
// is deterministic rather than relying on the sub-millisecond gap.
|
|
oldID, err := svc.CreateTask(schema.Task{Name: "Old", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
time.Sleep(5 * time.Millisecond)
|
|
newID, err := svc.CreateTask(schema.Task{Name: "New", Model: "m", Prompt: "p"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
listed := svc.ListTasks()
|
|
Expect(listed).To(HaveLen(2))
|
|
Expect(listed[0].ID).To(Equal(newID), "newest first")
|
|
Expect(listed[1].ID).To(Equal(oldID))
|
|
})
|
|
})
|
|
|
|
Describe("compile-time adapter contract", func() {
|
|
It("satisfies syncstate.Store for tasks", func() {
|
|
// Mirrors the var assertion in task_syncstore.go; keeps the type
|
|
// referenced from a spec so drift surfaces here too.
|
|
var _ syncstate.Store[string, schema.Task] = (*taskStoreAdapter)(nil)
|
|
Expect(&taskStoreAdapter{}).ToNot(BeNil())
|
|
})
|
|
})
|
|
})
|