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

524 lines
17 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
}
// A PR reaching a terminal state (merged or closed) no longer ends the
// session on its own: a session may own several PRs. Terminate only when no
// open PR remains and at least one of them merged. The observer persists the
// PR row before calling lifecycle, so the store already reflects this
// transition when sessionComplete reads it.
if o.Merged || o.Closed {
done, err := m.sessionComplete(ctx, id)
if err != nil {
return err
}
if done {
return m.MarkTerminated(ctx, id)
}
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 != "" {
// LogTail is raw CI job output; sanitize before it reaches the
// agent's live pane so embedded escape sequences can't drive the
// terminal (the dedup signature stays on the raw bytes).
msg += "\n\nFailing output:\n" + domain.SanitizeControlChars(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 {
// Only the bottom of a stack is eligible for the rebase nudge. A PR
// stacked on an open parent is expected to report conflicts against its
// parent branch until the parent merges and it retargets, so nudging the
// agent to rebase it now would be noise. Mergeability UNKNOWN (the brief
// post-retarget recompute window) never reaches here.
blocked, err := m.prBlockedByOpenParent(ctx, id, o.URL)
if err != nil {
return err
}
if blocked {
return nil
}
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
}
// sessionComplete reports whether the session has reached the multi-PR
// completion bar: at least one PR merged and no PR still open. A session with no
// PRs, or with any open PR, is not complete.
func (m *Manager) sessionComplete(ctx context.Context, id domain.SessionID) (bool, error) {
prs, err := m.store.ListPRsBySession(ctx, id)
if err != nil {
return false, err
}
merged := false
for _, pr := range prs {
if !pr.Merged && !pr.Closed {
return false, nil
}
if pr.Merged {
merged = true
}
}
return merged, nil
}
// prBlockedByOpenParent reports whether the PR at prURL is stacked on top of
// another still-open PR in the same session — i.e. its target branch is the
// source branch of a sibling open PR. Such a PR is not the bottom of its stack
// and is exempt from merge-conflict nudges. Branch facts are read from the
// store, which the observer has already updated for this observation.
func (m *Manager) prBlockedByOpenParent(ctx context.Context, id domain.SessionID, prURL string) (bool, error) {
prs, err := m.store.ListPRsBySession(ctx, id)
if err != nil {
return false, err
}
openSources := make(map[string]bool, len(prs))
for _, pr := range prs {
if !pr.Merged && !pr.Closed && pr.SourceBranch != "" {
openSources[pr.SourceBranch] = true
}
}
for _, pr := range prs {
if pr.URL == prURL {
return pr.TargetBranch != "" && openSources[pr.TargetBranch], nil
}
}
return false, 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
}
if err := m.ApplyPRObservation(ctx, id, scmToPRObservation(o)); err != nil {
return err
}
intent, err := m.notificationIntentForCurrentSCM(ctx, id, o)
if err != nil {
return err
}
m.emitNotification(ctx, intent)
return nil
}
func (m *Manager) notificationIntentForCurrentSCM(ctx context.Context, id domain.SessionID, o ports.SCMObservation) (*ports.NotificationIntent, error) {
// Serialize the session snapshot with activity transitions so ready-to-merge
// notifications do not race against a simultaneous waiting_input update.
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
return m.notificationIntentForSCM(rec, o), nil
}
func (m *Manager) notificationIntentForSCM(rec domain.SessionRecord, o ports.SCMObservation) *ports.NotificationIntent {
prURL := firstSCMNonEmpty(o.PR.URL, o.PR.HTMLURL)
base := ports.NotificationIntent{
SessionID: rec.ID,
ProjectID: rec.ProjectID,
PRURL: prURL,
CreatedAt: timeOr(o.ObservedAt, m.clock()),
SessionDisplayName: rec.DisplayName,
PRNumber: o.PR.Number,
PRTitle: o.PR.Title,
PRSourceBranch: o.PR.SourceBranch,
PRTargetBranch: o.PR.TargetBranch,
Provider: o.Provider,
Repo: o.Repo,
}
if o.PR.Merged {
base.Type = domain.NotificationPRMerged
return &base
}
if o.PR.Closed {
base.Type = domain.NotificationPRClosedUnmerged
return &base
}
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput || !scmObservationIsReadyToMerge(o) {
return nil
}
base.Type = domain.NotificationReadyToMerge
return &base
}
func scmObservationIsReadyToMerge(o ports.SCMObservation) bool {
if o.PR.Merged || o.PR.Closed || o.PR.Draft {
return false
}
ci := domain.CIState(o.CI.Summary)
if ci == "" {
ci = domain.CIUnknown
}
switch ci {
case domain.CIFailing, domain.CIPending, domain.CIUnknown:
return false
}
if domain.ReviewDecision(o.Review.Decision) == domain.ReviewChangesRequest || hasUnresolvedSCMComments(o.Review.Threads) {
return false
}
return domain.Mergeability(o.Mergeability.State) == domain.MergeMergeable
}
func hasUnresolvedSCMComments(threads []ports.SCMReviewThreadObservation) bool {
for _, th := range threads {
if th.Resolved || th.IsBot {
continue
}
for _, c := range th.Comments {
if !c.IsBot {
return true
}
}
}
return false
}
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
}
// Comment bodies are attacker-influenced (anyone who can comment on the
// PR) and get pasted into the agent's live pane; strip control/escape
// chars. The signature is built from comment IDs, not bodies, so dedup is
// unaffected.
bodies = append(bodies, domain.SanitizeControlChars(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+":")
}