agent-orchestrator/backend/internal/lifecycle/reactions.go

367 lines
11 KiB
Go

package lifecycle
import (
"context"
"encoding/json"
"log/slog"
"strings"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const reviewMaxNudge = 3
type reactionState struct {
mu sync.Mutex
seen map[string]string
attempts map[string]int
// loaded tracks PR URLs whose persisted dedup payload has been merged into
// seen/attempts during this process. Lazy: we only pay the DB read on the
// first reaction touching each PR after startup.
loaded map[string]bool
}
func newReactionState() reactionState {
return reactionState{seen: map[string]string{}, attempts: map[string]int{}, loaded: map[string]bool{}}
}
// reactionPayload is the JSON document persisted in pr.last_nudge_signature.
// Keeping the schema explicit (and stable) lets the daemon restart and resume
// the existing dedup state without re-nudging an agent.
type reactionPayload struct {
Seen map[string]string `json:"seen,omitempty"`
Attempts map[string]int `json:"attempts,omitempty"`
}
// ApplyPRObservation reacts to a fetched PR observation after the PR service has
// persisted it. It does not write PR rows; it owns PR-driven lifecycle effects
// and sends actionable agent nudges such as rebase, fix-CI, and
// address-review-feedback prompts.
func (m *Manager) ApplyPRObservation(ctx context.Context, id domain.SessionID, o ports.PRObservation) error {
if !o.Fetched {
return nil
}
if o.Merged {
return m.MarkTerminated(ctx, id)
}
if o.Closed {
return nil
}
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput {
return nil
}
if o.CI == domain.CIFailing {
for _, ch := range o.Checks {
if ch.Status == domain.PRCheckFailed {
msg := "CI is failing on your PR. Review the output below and push a fix."
if ch.LogTail != "" {
msg += "\n\nFailing output:\n" + ch.LogTail
}
return m.sendOnce(ctx, id, o.URL, "ci:"+o.URL+":"+ch.Name, ch.CommitHash+":"+ch.LogTail, msg, 0)
}
}
}
if o.Review == domain.ReviewChangesRequest || hasUnresolvedComments(o.Comments) {
comments, sig := reviewContent(o.Comments)
msg := "A reviewer left feedback on your PR. Address it and push."
if comments != "" {
msg += "\n\n" + comments
}
if sig == "" {
sig = string(o.Review)
}
return m.sendOnce(ctx, id, o.URL, "review:"+o.URL, sig, msg, reviewMaxNudge)
}
if o.Mergeability == domain.MergeConflicting {
return m.sendOnce(ctx, id, o.URL, "merge-conflict:"+o.URL, string(o.Mergeability), "Your PR has merge conflicts. Rebase onto the base branch and resolve them.", 0)
}
return nil
}
// ApplySCMObservation is the provider-neutral lifecycle entrypoint used by the
// SCM observer. The existing reaction logic still operates on PRObservation, so
// lifecycle performs the compatibility projection internally instead of leaking
// the old PR DTO back into the observer/provider boundary.
func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, o ports.SCMObservation) error {
if !o.Fetched {
return nil
}
return m.ApplyPRObservation(ctx, id, scmToPRObservation(o))
}
func scmToPRObservation(o ports.SCMObservation) ports.PRObservation {
pr := ports.PRObservation{
Fetched: o.Fetched,
URL: firstSCMNonEmpty(o.PR.URL, o.PR.HTMLURL),
Number: o.PR.Number,
Draft: o.PR.Draft,
Merged: o.PR.Merged,
Closed: o.PR.Closed,
CI: domain.CIState(o.CI.Summary),
Review: domain.ReviewDecision(o.Review.Decision),
Mergeability: domain.Mergeability(o.Mergeability.State),
}
if pr.CI == "" {
pr.CI = domain.CIUnknown
}
if pr.Review == "" {
pr.Review = domain.ReviewNone
}
if pr.Mergeability == "" {
pr.Mergeability = domain.MergeUnknown
}
checkCommit := firstSCMNonEmpty(o.CI.HeadSHA, o.PR.HeadSHA)
for _, ch := range o.CI.FailedChecks {
status := domain.PRCheckStatus(ch.Status)
if status == "" {
status = domain.PRCheckFailed
}
logTail := ch.LogTail
if logTail == "" {
logTail = o.CI.FailureLogTail
}
pr.Checks = append(pr.Checks, ports.PRCheckObservation{
Name: ch.Name,
CommitHash: checkCommit,
Status: status,
URL: ch.URL,
LogTail: logTail,
})
}
for _, th := range o.Review.Threads {
if th.Resolved || th.IsBot {
continue
}
for _, c := range th.Comments {
if c.IsBot {
continue
}
pr.Comments = append(pr.Comments, ports.PRCommentObservation{
ID: c.ID,
Author: c.Author,
File: th.Path,
Line: th.Line,
Body: c.Body,
Resolved: th.Resolved,
})
}
}
return pr
}
// ApplyTrackerFacts reacts to a fetched Tracker issue observation. It owns the
// issue-driven side of session lifecycle and the initial bot-mention nudge;
// it does NOT persist tracker rows (the future Tracker observer in #35 owns
// the read-side persistence path).
//
// Reactions today:
// - Issue terminal (state == done or cancelled) → MarkTerminated. The
// reducer is idempotent — repeat observations on an already-terminated
// session are no-ops because MarkTerminated skips when IsTerminated.
// - Assignee changed → log only. No session-state reaction yet; the policy
// for "assignee changed away from AO" is reserved for the write-side work
// tracked by #40.
// - New bot comment → one-time nudge using the same sendOnce + dedup
// signature pattern as the SCM lane. Dedup is in-memory only for now;
// cross-restart persistence lands with the Tracker observer (issue #35)
// when issue-row signature storage is on the table.
func (m *Manager) ApplyTrackerFacts(ctx context.Context, id domain.SessionID, o ports.TrackerObservation) error {
if !o.Fetched {
return nil
}
if isTerminalTrackerState(o.Issue.State) {
return m.MarkTerminated(ctx, id)
}
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput {
return nil
}
if o.Changed.Assignee {
slog.Default().Info("lifecycle: tracker issue assignee changed",
"session", id, "issue", o.Issue.URL, "assignee", o.Issue.Assignee)
}
if o.Changed.Comments {
bodies, ids := newBotCommentContent(o.Comments)
if len(ids) > 0 {
msg := "A bot left a new comment on your tracker issue. Address it and update the session."
if joined := strings.Join(bodies, "\n\n"); strings.TrimSpace(joined) != "" {
msg += "\n\n" + joined
}
// Empty prURL routes sendOnce through its in-memory-only branch:
// the PR-row signature load/persist is skipped, so the dedup
// survives only for the lifetime of this Manager. Cross-restart
// persistence ships with #35.
return m.sendOnce(ctx, id, "", "tracker-bot:"+o.Issue.URL, strings.Join(ids, ","), msg, 0)
}
}
return nil
}
func isTerminalTrackerState(state domain.NormalizedIssueState) bool {
return state == domain.IssueDone || state == domain.IssueCancelled
}
func newBotCommentContent(comments []ports.TrackerCommentObservation) ([]string, []string) {
bodies := make([]string, 0, len(comments))
ids := make([]string, 0, len(comments))
for _, c := range comments {
if !c.IsBot {
continue
}
// Both an ID and a body are required: ID anchors the dedup
// signature (an empty ID collapses to "" which collides with
// the zero value of m.react.seen[key] and silently suppresses
// the nudge), and a body is what we actually need to surface
// to the agent.
if c.ID == "" || strings.TrimSpace(c.Body) == "" {
continue
}
bodies = append(bodies, c.Body)
ids = append(ids, c.ID)
}
return bodies, ids
}
func firstSCMNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func hasUnresolvedComments(comments []ports.PRCommentObservation) bool {
for _, c := range comments {
if !c.Resolved {
return true
}
}
return false
}
func reviewContent(comments []ports.PRCommentObservation) (string, string) {
bodies := make([]string, 0, len(comments))
ids := make([]string, 0, len(comments))
for _, c := range comments {
if c.Resolved {
continue
}
bodies = append(bodies, c.Body)
ids = append(ids, c.ID)
}
return strings.Join(bodies, "\n\n"), strings.Join(ids, ",")
}
func (m *Manager) sendOnce(ctx context.Context, id domain.SessionID, prURL, key, sig, msg string, maxAttempts int) error {
if m.messenger == nil {
return nil
}
m.react.mu.Lock()
defer m.react.mu.Unlock()
if prURL != "" && !m.react.loaded[prURL] {
if err := m.loadPRSignaturesLocked(ctx, prURL); err != nil {
return err
}
m.react.loaded[prURL] = true
}
if m.react.seen[key] == sig {
return nil
}
attempts := m.react.attempts[key]
if maxAttempts > 0 && attempts >= maxAttempts {
return nil
}
if err := m.messenger.Send(ctx, id, msg); err != nil {
return err
}
// Order: Send → in-memory mutation → durable persist. Sending first means a
// transient persist failure does NOT swallow a real send (the agent saw the
// message; subsequent polls in this process suppress re-sends via the
// in-memory dedup). A persist failure that survives until a daemon restart
// degrades to one extra nudge — preferred over the inverse (persist before
// send, then crash mid-call) which would silently lose a real nudge.
m.react.seen[key] = sig
m.react.attempts[key] = attempts + 1
if prURL != "" {
if err := m.persistPRSignaturesLocked(ctx, prURL); err != nil {
return err
}
}
return nil
}
// loadPRSignaturesLocked merges any previously persisted reaction-dedup state
// for prURL into the in-memory maps. Caller must hold m.react.mu.
func (m *Manager) loadPRSignaturesLocked(ctx context.Context, prURL string) error {
raw, err := m.store.GetPRLastNudgeSignature(ctx, prURL)
if err != nil {
return err
}
if raw == "" {
return nil
}
// A corrupt persisted payload must not crash the lifecycle write path;
// the worst case from a swallow is re-firing a nudge once.
var p reactionPayload
_ = json.Unmarshal([]byte(raw), &p)
for k, v := range p.Seen {
if _, ok := m.react.seen[k]; !ok {
m.react.seen[k] = v
}
}
for k, v := range p.Attempts {
if cur, ok := m.react.attempts[k]; !ok || v > cur {
m.react.attempts[k] = v
}
}
return nil
}
// persistPRSignaturesLocked serialises every reaction-dedup entry whose key
// references prURL and writes the JSON payload back via the store. Caller must
// hold m.react.mu. A failed persist surfaces upward so the in-memory mutation
// (which the messenger already acted on) is not silently divergent from disk.
func (m *Manager) persistPRSignaturesLocked(ctx context.Context, prURL string) error {
payload := reactionPayload{Seen: map[string]string{}, Attempts: map[string]int{}}
for k, v := range m.react.seen {
if reactionKeyTargetsPR(k, prURL) {
payload.Seen[k] = v
}
}
for k, v := range m.react.attempts {
if reactionKeyTargetsPR(k, prURL) {
payload.Attempts[k] = v
}
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
return m.store.UpdatePRLastNudgeSignature(ctx, prURL, string(raw))
}
// reactionKeyTargetsPR matches the "<type>:<url>[:<extra>]" reaction keys used
// by ApplyPRObservation. Anchoring on the second colon-delimited segment keeps
// PR-specific keys grouped with the row that survives a restart.
func reactionKeyTargetsPR(key, prURL string) bool {
if prURL == "" {
return false
}
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 {
return false
}
rest := parts[1]
return rest == prURL || strings.HasPrefix(rest, prURL+":")
}