332 lines
11 KiB
Go
332 lines
11 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
var ctx = context.Background()
|
|
|
|
type fakeStore struct {
|
|
sessions map[domain.SessionID]domain.SessionRecord
|
|
signatures map[string]string
|
|
|
|
signatureWriteErr error
|
|
signatureWrites int
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, signatures: map[string]string{}}
|
|
}
|
|
|
|
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
|
r, ok := f.sessions[id]
|
|
return r, ok, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
|
|
f.sessions[rec.ID] = rec
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) GetPRLastNudgeSignature(_ context.Context, prURL string) (string, error) {
|
|
return f.signatures[prURL], nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdatePRLastNudgeSignature(_ context.Context, prURL, payload string) error {
|
|
if f.signatureWriteErr != nil {
|
|
return f.signatureWriteErr
|
|
}
|
|
if f.signatures == nil {
|
|
f.signatures = map[string]string{}
|
|
}
|
|
f.signatures[prURL] = payload
|
|
f.signatureWrites++
|
|
return nil
|
|
}
|
|
|
|
type fakeMessenger struct {
|
|
msgs []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.msgs = append(f.msgs, msg)
|
|
return nil
|
|
}
|
|
|
|
func newManager() (*Manager, *fakeStore, *fakeMessenger) {
|
|
st := newFakeStore()
|
|
msg := &fakeMessenger{}
|
|
return New(st, msg), st, msg
|
|
}
|
|
|
|
func working(id domain.SessionID) domain.SessionRecord {
|
|
return domain.SessionRecord{ID: id, ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive, LastActivityAt: time.Now()}}
|
|
}
|
|
|
|
func TestRuntimeObservation_InferredDeathSetsTerminated(t *testing.T) {
|
|
m, st, _ := newManager()
|
|
rec := working("mer-1")
|
|
rec.Activity.LastActivityAt = time.Now().Add(-2 * time.Minute)
|
|
st.sessions["mer-1"] = rec
|
|
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeDead}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := st.sessions["mer-1"]
|
|
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
|
|
t.Fatalf("want terminated/exited, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeObservation_FailedProbeDoesNotMutate(t *testing.T) {
|
|
m, st, _ := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
before := st.sessions["mer-1"]
|
|
if err := m.ApplyRuntimeObservation(ctx, "mer-1", ports.RuntimeFacts{Probe: ports.ProbeFailed}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.sessions["mer-1"] != before {
|
|
t.Fatalf("failed probe should not persist a state, got %+v", st.sessions["mer-1"])
|
|
}
|
|
}
|
|
|
|
func TestActivity_InvalidIsIgnored(t *testing.T) {
|
|
m, st, _ := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
before := st.sessions["mer-1"]
|
|
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: false, State: domain.ActivityIdle}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.sessions["mer-1"] != before {
|
|
t.Fatal("invalid signal must not mutate")
|
|
}
|
|
}
|
|
|
|
func TestMarkTerminated(t *testing.T) {
|
|
m, st, _ := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
if err := m.MarkTerminated(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := st.sessions["mer-1"]
|
|
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
|
|
t.Fatalf("want terminated/exited, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestMarkSpawnedStoresRuntimeMetadata(t *testing.T) {
|
|
m, st, _ := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true}
|
|
metadata := domain.SessionMetadata{Branch: "b", WorkspacePath: "/ws", RuntimeHandleID: "h1", AgentSessionID: "agent", Prompt: "prompt"}
|
|
if err := m.MarkSpawned(ctx, "mer-1", metadata); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := st.sessions["mer-1"]
|
|
if got.IsTerminated || got.Activity.State != domain.ActivityIdle || got.Metadata.RuntimeHandleID != "h1" {
|
|
t.Fatalf("spawn metadata wrong: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_CIFailingNudgesAgentWithLogs(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.PRObservation{Fetched: true, URL: "pr1", CI: domain.CIFailing, Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}}}
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "boom") {
|
|
t.Fatalf("want one CI nudge with log tail, got %v", msg.msgs)
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_ReviewCommentsNudgeAgent(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.PRObservation{Fetched: true, URL: "pr1", Review: domain.ReviewChangesRequest, Comments: []ports.PRCommentObservation{{ID: "1", Body: "fix this"}}}
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "fix this") {
|
|
t.Fatalf("want review nudge, got %v", msg.msgs)
|
|
}
|
|
}
|
|
|
|
func TestSCMObservationProjectsToExistingPRReactions(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.SCMObservation{
|
|
Fetched: true,
|
|
PR: ports.SCMPRObservation{URL: "pr1", Number: 1},
|
|
CI: ports.SCMCIObservation{
|
|
Summary: string(domain.CIFailing),
|
|
HeadSHA: "c1",
|
|
FailedChecks: []ports.SCMCheckObservation{{
|
|
Name: "build", Status: string(domain.PRCheckFailed), LogTail: "boom",
|
|
}},
|
|
},
|
|
}
|
|
if err := m.ApplySCMObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "boom") {
|
|
t.Fatalf("want SCM CI nudge with log tail, got %v", msg.msgs)
|
|
}
|
|
}
|
|
|
|
func TestSCMObservationUsesPRHeadWhenCIHeadMissing(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.SCMObservation{
|
|
Fetched: true,
|
|
PR: ports.SCMPRObservation{URL: "pr1", HeadSHA: "c1"},
|
|
CI: ports.SCMCIObservation{
|
|
Summary: string(domain.CIFailing),
|
|
FailedChecks: []ports.SCMCheckObservation{{
|
|
Name: "build", Status: string(domain.PRCheckFailed),
|
|
}},
|
|
},
|
|
}
|
|
if err := m.ApplySCMObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.PR.HeadSHA = "c2"
|
|
if err := m.ApplySCMObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 2 {
|
|
t.Fatalf("want separate CI nudges for distinct PR heads when CI head is absent, got %d: %v", len(msg.msgs), msg.msgs)
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_MergeConflictNudgesAgent(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting}
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 1 || !strings.Contains(msg.msgs[0], "merge conflicts") {
|
|
t.Fatalf("want merge-conflict nudge, got %v", msg.msgs)
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_MergedTerminatesWithoutNudge(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", ports.PRObservation{Fetched: true, URL: "pr1", Merged: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := st.sessions["mer-1"]
|
|
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
|
|
t.Fatalf("merged PR should terminate session, got %+v", got)
|
|
}
|
|
if len(msg.msgs) != 0 {
|
|
t.Fatalf("merged PR should not send nudge, got %v", msg.msgs)
|
|
}
|
|
}
|
|
|
|
// TestPRObservation_DedupSurvivesManagerRestart simulates a daemon restart by
|
|
// constructing a second Manager over the same store and asserts that an
|
|
// identical PR observation does not re-fire the nudge — the dedup signature
|
|
// must survive process restart, not just live in the Manager's maps.
|
|
func TestPRObservation_DedupSurvivesManagerRestart(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
|
|
o := ports.PRObservation{
|
|
Fetched: true,
|
|
URL: "https://github.com/o/r/pull/1",
|
|
CI: domain.CIFailing,
|
|
Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}},
|
|
}
|
|
|
|
first := &fakeMessenger{}
|
|
m1 := New(st, first)
|
|
if err := m1.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatalf("first ApplyPRObservation: %v", err)
|
|
}
|
|
if len(first.msgs) != 1 {
|
|
t.Fatalf("first manager: want 1 nudge, got %d", len(first.msgs))
|
|
}
|
|
if got := st.signatures[o.URL]; got == "" {
|
|
t.Fatalf("signature was not persisted; want a non-empty JSON payload for %q", o.URL)
|
|
}
|
|
|
|
// Simulate daemon restart: the second Manager has no in-memory state but
|
|
// shares the same store, so it should hydrate seen/attempts from the
|
|
// persisted payload and suppress the re-send.
|
|
second := &fakeMessenger{}
|
|
m2 := New(st, second)
|
|
if err := m2.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatalf("second ApplyPRObservation: %v", err)
|
|
}
|
|
if len(second.msgs) != 0 {
|
|
t.Fatalf("post-restart manager re-nudged on identical observation, got %d msgs: %v", len(second.msgs), second.msgs)
|
|
}
|
|
|
|
// And a genuinely new signature (different log tail) still fires — proving
|
|
// the persisted state is per-signature, not a blanket "this PR was nudged".
|
|
o2 := o
|
|
o2.Checks = []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "different boom"}}
|
|
if err := m2.ApplyPRObservation(ctx, "mer-1", o2); err != nil {
|
|
t.Fatalf("third ApplyPRObservation: %v", err)
|
|
}
|
|
if len(second.msgs) != 1 {
|
|
t.Fatalf("new signature should send, got %d msgs", len(second.msgs))
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_DedupPersistsAcrossPRs(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
msg := &fakeMessenger{}
|
|
m := New(st, msg)
|
|
|
|
for _, url := range []string{"https://github.com/o/r/pull/1", "https://github.com/o/r/pull/2"} {
|
|
o := ports.PRObservation{
|
|
Fetched: true, URL: url, CI: domain.CIFailing,
|
|
Checks: []ports.PRCheckObservation{{Name: "build", CommitHash: "c1", Status: domain.PRCheckFailed, LogTail: "boom"}},
|
|
}
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatalf("ApplyPRObservation for %s: %v", url, err)
|
|
}
|
|
}
|
|
if len(msg.msgs) != 2 {
|
|
t.Fatalf("distinct PRs should each get one nudge, got %d", len(msg.msgs))
|
|
}
|
|
if _, ok := st.signatures["https://github.com/o/r/pull/1"]; !ok {
|
|
t.Fatal("missing persisted signature for PR 1")
|
|
}
|
|
if _, ok := st.signatures["https://github.com/o/r/pull/2"]; !ok {
|
|
t.Fatal("missing persisted signature for PR 2")
|
|
}
|
|
}
|
|
|
|
func TestPRObservation_RetriesAfterMessengerFailure(t *testing.T) {
|
|
m, st, msg := newManager()
|
|
st.sessions["mer-1"] = working("mer-1")
|
|
o := ports.PRObservation{Fetched: true, URL: "pr1", Mergeability: domain.MergeConflicting}
|
|
msg.err = errors.New("temporary send failure")
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err == nil {
|
|
t.Fatal("want send error")
|
|
}
|
|
msg.err = nil
|
|
if err := m.ApplyPRObservation(ctx, "mer-1", o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msg.msgs) != 1 {
|
|
t.Fatalf("want retry to send once, got %v", msg.msgs)
|
|
}
|
|
}
|