524 lines
17 KiB
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+":")
|
|
}
|