feat(tracker): ApplyTrackerFacts reducer + shared observer skeleton (#112) (#116)

* refactor(observe): extract shared observer skeleton

Move the observer-pattern-general pieces of the SCM observer into a new
backend/internal/observe package so the tracker observer (issue #35) can
build on the same primitives:

- StartPollLoop: goroutine supervisor with immediate-first-poll + ticker
  + ctx-done exit. SCM Observer.Start now delegates to it.
- CheckCredentialsOnce: lazy first-poll credential gate driven by a
  CredentialProbe closure. SCM observer keeps credentialsChecked/disabled
  as Observer fields; the shared helper mutates them via pointer so
  state ownership stays single-source.
- CacheSet[V any] / CacheDelete[V any]: one generic bounded-FIFO helper
  replaces the three near-identical cacheSet{String,Time,Bool} bodies
  and the standalone evictStrings. The SCM-side methods are now
  one-line wrappers that thread o.Cache.max into the shared helper, so
  existing call sites and tests are untouched.

SCM behavior is unchanged. The full 21-test SCM suite (including the
end-to-end test added in PR #115) plus 577 backend tests stay green
under `go test -race`.

Part of #112.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tracker): ports.TrackerObservation DTO + ApplyTrackerFacts reducer

Land the contract that the future Tracker observer (issue #35) and its
provider adapters must satisfy. No observer is wired in this PR — the
DTO + reducer are the deliverable, and locking the shape now lets the
observer + adapter work happen in small follow-up PRs.

DTO (backend/internal/ports/tracker_observations.go):
- TrackerObservation mirrors ports.SCMObservation: Fetched bool,
  ObservedAt time.Time, Provider/Host/Repo, normalized Issue facts,
  Comments, and a Changed{State, Assignee, Comments} discriminator.
- TrackerIssueObservation carries the minimal facts lifecycle needs
  today (state, assignee, title, body, timestamps); richer
  per-provider metadata stays inside each adapter.
- TrackerCommentObservation carries the comment fields needed for the
  bot-mention nudge (Author, Body, IsBot, ID for dedup).

Reducer (backend/internal/lifecycle/reactions.go):
- ApplyTrackerFacts(ctx, sessionID, ports.TrackerObservation) error,
  mirroring ApplySCMObservation's "Fetched gate → terminal-state →
  per-bucket reactions" shape.
- Three initial reactions:
    * Issue state == done | cancelled → MarkTerminated (idempotent).
    * Changed.Assignee → log only via slog.Default(). The "assignee
      changed away from AO" policy is reserved for #40.
    * Changed.Comments with bot comments → one-time nudge with
      strings.Join'd bot bodies, deduped by comment IDs.
- The nudge path reuses sendOnce with an empty prURL so the in-memory
  dedup applies but the PR-row persistence path is skipped. Tracker
  signature persistence will land with #35 alongside issue-row storage.

Tests in backend/internal/lifecycle/manager_test.go cover each branch:
terminate (done + cancelled), log-only assignee, nudge fires on new
bot comment, nudge suppressed on repeat, new bot comment id refires,
not-fetched is no-op, terminated session ignores observations.

Part of #112.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(observe): rename CacheSet param to avoid shadowing built-in max

golangci-lint revive flagged the CacheSet generic helper's max
parameter as shadowing the built-in max() function. Rename to
maxEntries; signature change is internal to the observe package and
the SCM observer's one-line wrappers pass the value positionally, so
no call sites need updating.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: honour disabled state in CheckCredentialsOnce + tighten bot-comment filter

Two P1 review findings on #116:

1. observe.CheckCredentialsOnce was returning (true, nil) on every
   call after the gate ran, even when the probe had marked the
   observer disabled, because the *checked short-circuit ignored
   *disabled. The SCM observer didn't surface this in practice — its
   Poll method has an independent `if o.disabled { return nil }`
   guard that runs first — but a future Tracker observer that relies
   on the helper's documented contract ("Observer stays disabled")
   would silently flip back to "credentials available" after the
   first poll. Change the short-circuit to `return !*disabled, nil`
   and lock the behavior with a regression test that issues repeat
   calls after the probe reported unavailable.

2. lifecycle.newBotCommentContent's "skip uninteresting comments"
   filter used && where it needed ||. A bot comment with an empty ID
   but a non-empty body slipped through and appended "" to the ids
   slice. If every bot comment in the observation had an empty ID,
   strings.Join produced "" — which matches the zero value of the
   in-memory dedup map, so sendOnce treated the nudge as
   already-sent and silently suppressed it forever. Switch to || so
   any comment missing either an ID or a body is dropped, and add a
   regression test that an empty-ID bot comment never nudges (and
   does not pollute the dedup state for a follow-up comment that has
   a real ID).

586 tests pass with -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(observe): capture deadline once in poll-error spin-wait

The `TestStartPollLoop_LogsPollErrorWithoutPanic` spin-wait was
computing the loop bound as `time.Now().Before(time.Now().Add(200ms))`
on every iteration, which is permanently true — the loop could only
exit via the `break`. Under a scheduler delay (heavy CI load or
`GOMAXPROCS=1`) where two polls never land in time, the test would
hang until the wall-clock kill rather than failing fast.

Capture the deadline once before the loop, and tighten the assertion
to actually require two polls + done-channel closure within a bounded
window, matching `TestStartPollLoop_FirstPollImmediateThenTicks`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-07 21:29:00 +05:30 committed by GitHub
parent c343c55c14
commit 33a1ee6df9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 748 additions and 94 deletions

View File

@ -313,6 +313,174 @@ func TestPRObservation_DedupPersistsAcrossPRs(t *testing.T) {
}
}
func TestApplyTrackerFacts_TerminalStateMarksTerminated(t *testing.T) {
for _, state := range []domain.NormalizedIssueState{domain.IssueDone, domain.IssueCancelled} {
t.Run(string(state), func(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: state},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
got := st.sessions["mer-1"]
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
t.Fatalf("want terminated/exited for state %q, got %+v", state, got)
}
if len(msg.msgs) != 0 {
t.Fatalf("terminal state should not nudge, got %v", msg.msgs)
}
})
}
}
func TestApplyTrackerFacts_AssigneeChangedIsLogOnly(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen, Assignee: "someone-else"},
Changed: ports.TrackerChanged{Assignee: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if st.sessions["mer-1"] != before {
t.Fatalf("assignee-only change must not mutate the session row, got %+v", st.sessions["mer-1"])
}
if len(msg.msgs) != 0 {
t.Fatalf("assignee-only change must not nudge, got %v", msg.msgs)
}
}
func TestApplyTrackerFacts_NewBotCommentNudges(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "human-1", Author: "alice", Body: "human chime-in, must NOT nudge", IsBot: false},
{ID: "bot-1", Author: "ci-bot[bot]", Body: "please rerun the migration", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("want one bot-mention nudge, got %d: %v", len(msg.msgs), msg.msgs)
}
if !strings.Contains(msg.msgs[0], "please rerun the migration") {
t.Fatalf("nudge should include the bot comment body, got %q", msg.msgs[0])
}
if strings.Contains(msg.msgs[0], "human chime-in") {
t.Fatalf("nudge must not include human comments, got %q", msg.msgs[0])
}
}
func TestApplyTrackerFacts_NudgeSuppressedOnRepeat(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "bot-1", Author: "ci-bot[bot]", Body: "please rerun the migration", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("first ApplyTrackerFacts: %v", err)
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("second ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("repeat observation must dedup; got %d nudges: %v", len(msg.msgs), msg.msgs)
}
// A genuinely new bot comment still fires.
o.Comments = append(o.Comments, ports.TrackerCommentObservation{ID: "bot-2", Author: "ci-bot[bot]", Body: "now check the seed", IsBot: true})
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("third ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 2 {
t.Fatalf("new bot comment id should re-fire, got %d: %v", len(msg.msgs), msg.msgs)
}
}
func TestApplyTrackerFacts_BotCommentWithEmptyIDIsIgnored(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
// Bot comment lacks an ID — without one we cannot dedup, and the
// zero-value signature collides with m.react.seen's empty default and
// would silently suppress every future nudge for this issue. The
// reducer must skip it entirely.
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "", Author: "ci-bot[bot]", Body: "no id, must be skipped", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 0 {
t.Fatalf("bot comment with empty ID must not nudge, got %v", msg.msgs)
}
// A subsequent, properly-formed bot comment must still nudge — the
// earlier empty-ID entry must not have polluted the dedup signature.
o.Comments = []ports.TrackerCommentObservation{
{ID: "bot-1", Author: "ci-bot[bot]", Body: "now with an id", IsBot: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("second ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("follow-up bot comment with real ID should nudge, got %d: %v", len(msg.msgs), msg.msgs)
}
}
func TestApplyTrackerFacts_NotFetchedIsNoop(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
if err := m.ApplyTrackerFacts(ctx, "mer-1", ports.TrackerObservation{Fetched: false}); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if st.sessions["mer-1"] != before {
t.Fatalf("not-fetched observation must not mutate state")
}
if len(msg.msgs) != 0 {
t.Fatalf("not-fetched observation must not nudge")
}
}
func TestApplyTrackerFacts_TerminatedSessionDoesNotRefireOrNudge(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "bot-1", Body: "x", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 0 {
t.Fatalf("terminated session must not receive nudges, got %v", msg.msgs)
}
}
func TestPRObservation_RetriesAfterMessengerFailure(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")

View File

@ -3,6 +3,7 @@ package lifecycle
import (
"context"
"encoding/json"
"log/slog"
"strings"
"sync"
@ -154,6 +155,82 @@ func scmToPRObservation(o ports.SCMObservation) ports.PRObservation {
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

View File

@ -0,0 +1,136 @@
// Package observe contains observer-pattern primitives shared across the SCM
// and Tracker observation lanes. The pieces here are deliberately
// provider-agnostic: a polling goroutine supervisor, a lazy credential gate,
// and a bounded FIFO cache helper. Provider-specific normalization,
// persistence, and lifecycle reactions live in the sibling packages
// (observe/scm, future observe/tracker).
package observe
import (
"context"
"errors"
"log/slog"
"time"
)
// StartPollLoop launches a goroutine that calls poll immediately, then on every
// tick interval until ctx is done. The returned channel closes when the
// goroutine exits; callers wait on it during shutdown.
//
// The immediate first poll inside the goroutine (rather than before the ticker
// loop) keeps daemon startup non-blocking: callers see Start return after the
// goroutine is launched, not after the first network call.
//
// poll errors other than context.Canceled are logged via logger with name as a
// prefix, e.g. name="scm observer" -> "scm observer: initial poll failed".
func StartPollLoop(ctx context.Context, tick time.Duration, poll func(context.Context) error, logger *slog.Logger, name string) <-chan struct{} {
if logger == nil {
logger = slog.Default()
}
done := make(chan struct{})
go func() {
defer close(done)
if err := poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Error(name+": initial poll failed", "err", err)
}
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Error(name+": poll failed", "err", err)
}
}
}
}()
return done
}
// CredentialProbe checks whether the observer's provider has usable credentials.
// Implementations return (false, nil) for a transient failure the observer
// should retry on the next tick, (false, non-nil) for an error the caller
// should surface, and (true, nil) when credentials are available.
type CredentialProbe func(ctx context.Context) (available bool, err error)
// CheckCredentialsOnce runs probe at most once. The caller owns checked/disabled
// state via pointer so the observer struct keeps a single source of truth.
//
// State transitions:
// - probe == nil → *checked = true, returns (true, nil). No gate.
// - probe returns err → state unchanged, returns (false, nil). Retried next tick.
// - probe returns false → *checked = true, *disabled = true, returns (false, nil). Observer stays disabled.
// - probe returns true → *checked = true, returns (true, nil). Subsequent calls bypass the probe.
//
// Subsequent calls after the probe has run return (!*disabled, nil) so the
// disabled verdict is honoured on every poll, not just the first one.
//
// A context-cancellation before the probe returns (false, ctx.Err()).
func CheckCredentialsOnce(ctx context.Context, probe CredentialProbe, checked, disabled *bool, logger *slog.Logger, name string) (bool, error) {
if *checked {
return !*disabled, nil
}
if err := ctx.Err(); err != nil {
return false, err
}
if logger == nil {
logger = slog.Default()
}
if probe == nil {
*checked = true
return true, nil
}
available, err := probe(ctx)
if err != nil {
logger.Warn(name+" credentials check failed; will retry", "err", err)
return false, nil
}
*checked = true
if !available {
*disabled = true
logger.Warn(name + " disabled: provider credentials unavailable")
return false, nil
}
return true, nil
}
// CacheSet writes value to m[key] and tracks insertion order in *order for
// bounded FIFO eviction. If the bucket already had key, order is left
// unchanged; otherwise key is appended. When len(*order) exceeds maxEntries,
// the oldest keys are evicted from both order and m.
//
// maxEntries <= 0 disables eviction; callers that want bounded behavior must
// pass a positive value. The generic shape lets the same helper serve
// string-, time-, and bool-valued caches without per-type duplication.
func CacheSet[V any](m map[string]V, order *[]string, maxEntries int, key string, value V) {
if _, ok := m[key]; !ok {
*order = append(*order, key)
}
m[key] = value
if maxEntries <= 0 {
return
}
for len(*order) > maxEntries {
evict := (*order)[0]
*order = (*order)[1:]
delete(m, evict)
}
}
// CacheDelete removes key from m and the matching slot from *order. It is a
// no-op when key is absent.
func CacheDelete[V any](m map[string]V, order *[]string, key string) {
if _, ok := m[key]; !ok {
return
}
delete(m, key)
dst := (*order)[:0]
for _, cachedKey := range *order {
if cachedKey != key {
dst = append(dst, cachedKey)
}
}
*order = dst
}

View File

@ -0,0 +1,230 @@
package observe
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestCacheSet_InsertsAndOrders(t *testing.T) {
m := map[string]string{}
var order []string
CacheSet(m, &order, 4, "a", "1")
CacheSet(m, &order, 4, "b", "2")
CacheSet(m, &order, 4, "c", "3")
if got := m["b"]; got != "2" {
t.Fatalf("m[b] = %q want %q", got, "2")
}
if len(order) != 3 || order[0] != "a" || order[2] != "c" {
t.Fatalf("order = %v, want [a b c]", order)
}
}
func TestCacheSet_UpdateDoesNotRepeatOrder(t *testing.T) {
m := map[string]string{}
var order []string
CacheSet(m, &order, 4, "a", "1")
CacheSet(m, &order, 4, "a", "1b")
if got := m["a"]; got != "1b" {
t.Fatalf("m[a] = %q want %q", got, "1b")
}
if len(order) != 1 || order[0] != "a" {
t.Fatalf("order = %v, want [a] (repeat sets must not duplicate the slot)", order)
}
}
func TestCacheSet_EvictsOldestPastMax(t *testing.T) {
m := map[string]int{}
var order []string
for i, k := range []string{"a", "b", "c", "d"} {
CacheSet(m, &order, 2, k, i)
}
if _, ok := m["a"]; ok {
t.Fatalf("a should have been evicted, got %v", m)
}
if _, ok := m["b"]; ok {
t.Fatalf("b should have been evicted, got %v", m)
}
if len(order) != 2 || order[0] != "c" || order[1] != "d" {
t.Fatalf("order = %v, want [c d]", order)
}
}
func TestCacheSet_GenericOverTime(t *testing.T) {
m := map[string]time.Time{}
var order []string
now := time.Unix(1700000000, 0)
CacheSet(m, &order, 4, "k", now)
if !m["k"].Equal(now) {
t.Fatalf("time round-trip failed: %v vs %v", m["k"], now)
}
}
func TestCacheDelete_RemovesKeyAndOrderSlot(t *testing.T) {
m := map[string]bool{}
var order []string
CacheSet(m, &order, 4, "a", true)
CacheSet(m, &order, 4, "b", true)
CacheDelete(m, &order, "a")
if _, ok := m["a"]; ok {
t.Fatalf("a should be removed: %v", m)
}
if len(order) != 1 || order[0] != "b" {
t.Fatalf("order = %v, want [b]", order)
}
}
func TestCacheDelete_MissingKeyIsNoop(t *testing.T) {
m := map[string]bool{"a": true}
order := []string{"a"}
CacheDelete(m, &order, "z")
if !m["a"] || len(order) != 1 || order[0] != "a" {
t.Fatalf("missing-key delete must not mutate, got m=%v order=%v", m, order)
}
}
func TestCheckCredentialsOnce_NilProbeMarksChecked(t *testing.T) {
var checked, disabled bool
ok, err := CheckCredentialsOnce(context.Background(), nil, &checked, &disabled, quietLogger(), "test")
if err != nil || !ok {
t.Fatalf("nil probe: ok=%v err=%v", ok, err)
}
if !checked || disabled {
t.Fatalf("nil probe: checked=%v disabled=%v", checked, disabled)
}
}
func TestCheckCredentialsOnce_ProbeAvailable(t *testing.T) {
var checked, disabled bool
calls := 0
probe := func(context.Context) (bool, error) { calls++; return true, nil }
if ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test"); err != nil || !ok {
t.Fatalf("first call: ok=%v err=%v", ok, err)
}
if !checked || disabled {
t.Fatalf("after success: checked=%v disabled=%v", checked, disabled)
}
// Second call must NOT re-invoke probe.
if ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test"); err != nil || !ok {
t.Fatalf("second call: ok=%v err=%v", ok, err)
}
if calls != 1 {
t.Fatalf("probe should run once, ran %d", calls)
}
}
func TestCheckCredentialsOnce_ProbeUnavailableDisables(t *testing.T) {
var checked, disabled bool
calls := 0
probe := func(context.Context) (bool, error) { calls++; return false, nil }
ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test")
if err != nil || ok {
t.Fatalf("ok=%v err=%v, want (false, nil)", ok, err)
}
if !checked || !disabled {
t.Fatalf("after unavailable: checked=%v disabled=%v", checked, disabled)
}
// Subsequent calls must keep reporting (false, nil) — the short-circuit
// on *checked still has to honour *disabled, otherwise a disabled
// observer's Poll path silently flips back to "credentials available".
for i := 0; i < 3; i++ {
ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test")
if err != nil || ok {
t.Fatalf("repeat call %d: ok=%v err=%v, want (false, nil)", i, ok, err)
}
}
if calls != 1 {
t.Fatalf("probe should run exactly once even when disabled, ran %d times", calls)
}
}
func TestCheckCredentialsOnce_TransientErrorRetries(t *testing.T) {
var checked, disabled bool
calls := 0
probe := func(context.Context) (bool, error) {
calls++
if calls == 1 {
return false, errors.New("transient")
}
return true, nil
}
if ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test"); err != nil || ok {
t.Fatalf("first call: ok=%v err=%v, want (false,nil)", ok, err)
}
if checked || disabled {
t.Fatalf("transient error must leave state untouched: checked=%v disabled=%v", checked, disabled)
}
if ok, err := CheckCredentialsOnce(context.Background(), probe, &checked, &disabled, quietLogger(), "test"); err != nil || !ok {
t.Fatalf("retry call: ok=%v err=%v, want (true,nil)", ok, err)
}
}
func TestStartPollLoop_FirstPollImmediateThenTicks(t *testing.T) {
var mu sync.Mutex
calls := 0
poll := func(context.Context) error {
mu.Lock()
defer mu.Unlock()
calls++
return nil
}
ctx, cancel := context.WithCancel(context.Background())
done := StartPollLoop(ctx, 10*time.Millisecond, poll, quietLogger(), "test")
// Wait for at least 2 polls (initial + one tick).
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := calls
mu.Unlock()
if n >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("done channel not closed after cancel")
}
mu.Lock()
defer mu.Unlock()
if calls < 2 {
t.Fatalf("expected at least 2 polls, got %d", calls)
}
}
func TestStartPollLoop_LogsPollErrorWithoutPanic(t *testing.T) {
var ran atomic.Int32
poll := func(context.Context) error {
ran.Add(1)
return errors.New("boom")
}
ctx, cancel := context.WithCancel(context.Background())
done := StartPollLoop(ctx, 10*time.Millisecond, poll, quietLogger(), "test")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if ran.Load() >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("done channel not closed after cancel")
}
if ran.Load() < 2 {
t.Fatalf("expected at least 2 polls under error path, got %d", ran.Load())
}
}

View File

@ -14,9 +14,11 @@ import (
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/observe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -160,39 +162,25 @@ func New(provider Provider, store Store, lifecycle Lifecycle, cfg Config) *Obser
// Start launches the observer loop. The first Poll runs immediately inside the
// goroutine so daemon startup is not blocked; subsequent polls run on the tick.
//
// The first invocation of poll inside the supervisor also runs checkCredentials
// up front. That way the "scm observer disabled: provider credentials
// unavailable" warning is emitted on a fresh daemon even if discoverSubjects
// has no subjects yet (which would otherwise short-circuit Poll before
// checkCredentials). checkCredentials is guarded by credentialsChecked, so the
// wrap stays once-per-process; a transient error there simply defers the check
// to the next tick.
func (o *Observer) Start(ctx context.Context) <-chan struct{} {
done := make(chan struct{})
go o.loop(ctx, done)
return done
}
func (o *Observer) loop(ctx context.Context, done chan<- struct{}) {
defer close(done)
// Run the credential gate once before the first poll so the
// "scm observer disabled: provider credentials unavailable" warning is
// emitted on a fresh daemon even if discoverSubjects has no subjects yet
// (which would otherwise short-circuit Poll before checkCredentials).
// checkCredentials is guarded by credentialsChecked, so this remains
// once-per-process; a transient error here simply defers the check to the
// next Poll, matching existing behavior.
if _, err := o.checkCredentials(ctx); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("scm observer: initial credential check failed", "err", err)
}
if err := o.Poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("scm observer: initial poll failed", "err", err)
}
t := time.NewTicker(o.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := o.Poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("scm observer: poll failed", "err", err)
var credentialGate sync.Once
poll := func(ctx context.Context) error {
credentialGate.Do(func() {
if _, err := o.checkCredentials(ctx); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("scm observer: initial credential check failed", "err", err)
}
}
})
return o.Poll(ctx)
}
return observe.StartPollLoop(ctx, o.tick, poll, o.logger, "scm observer")
}
type subject struct {
@ -410,29 +398,11 @@ func (o *Observer) Poll(ctx context.Context) error {
}
func (o *Observer) checkCredentials(ctx context.Context) (bool, error) {
if o.credentialsChecked {
return true, nil
var probe observe.CredentialProbe
if checker, ok := o.provider.(credentialChecker); ok {
probe = checker.SCMCredentialsAvailable
}
if err := ctx.Err(); err != nil {
return false, err
}
checker, ok := o.provider.(credentialChecker)
if !ok {
o.credentialsChecked = true
return true, nil
}
available, err := checker.SCMCredentialsAvailable(ctx)
if err != nil {
o.logger.Warn("scm observer credentials check failed; will retry", "err", err)
return false, nil
}
o.credentialsChecked = true
if !available {
o.disabled = true
o.logger.Warn("scm observer disabled: provider credentials unavailable")
return false, nil
}
return true, nil
return observe.CheckCredentialsOnce(ctx, probe, &o.credentialsChecked, &o.disabled, o.logger, "scm observer")
}
func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, error) {
@ -1162,56 +1132,22 @@ func scrubLine(s string) string {
return strings.TrimSpace(s)
}
// cacheSetString / cacheSetTime / cacheSetBool are thin wrappers around the
// generic observe.CacheSet helper, kept on Observer so callers don't need to
// thread o.Cache.max through every invocation. The single shared
// implementation lives in the observe package.
func (o *Observer) cacheSetString(m map[string]string, order *[]string, key, value string) {
if _, ok := m[key]; !ok {
*order = append(*order, key)
}
m[key] = value
o.evictStrings(m, order)
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func (o *Observer) cacheSetTime(m map[string]time.Time, order *[]string, key string, value time.Time) {
if _, ok := m[key]; !ok {
*order = append(*order, key)
}
m[key] = value
for len(*order) > o.Cache.max {
evict := (*order)[0]
*order = (*order)[1:]
delete(m, evict)
}
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func (o *Observer) cacheSetBool(m map[string]bool, order *[]string, key string, value bool) {
if _, ok := m[key]; !ok {
*order = append(*order, key)
}
m[key] = value
for len(*order) > o.Cache.max {
evict := (*order)[0]
*order = (*order)[1:]
delete(m, evict)
}
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func cacheDelete[V any](m map[string]V, order *[]string, key string) {
if _, ok := m[key]; !ok {
return
}
delete(m, key)
dst := (*order)[:0]
for _, cachedKey := range *order {
if cachedKey != key {
dst = append(dst, cachedKey)
}
}
*order = dst
}
func (o *Observer) evictStrings(m map[string]string, order *[]string) {
for len(*order) > o.Cache.max {
evict := (*order)[0]
*order = (*order)[1:]
delete(m, evict)
}
observe.CacheDelete(m, order, key)
}

View File

@ -0,0 +1,107 @@
// This file defines provider-neutral Tracker DTOs used at the boundary between
// the (future) Tracker observer, persistence layer, and lifecycle manager. The
// shape mirrors ports.SCMObservation so the lifecycle reducer in
// lifecycle.Manager has the same "Fetched + ObservedAt + normalized facts +
// Changed discriminator" contract for both lanes.
package ports
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// TrackerObservation is the provider-neutral issue observation emitted by the
// Tracker observer and consumed by lifecycle.Manager.ApplyTrackerFacts.
// Provider adapters normalize their tracker-specific payloads into this DTO
// before the observer persists/notifies.
type TrackerObservation struct {
// Fetched is true only when the provider refresh succeeded and the nested
// facts are authoritative for this poll.
Fetched bool
// ObservedAt is the observer timestamp for this normalized snapshot.
ObservedAt time.Time
// Provider is the normalized tracker provider name, e.g. "github".
Provider string
// Host is the tracker host that served this observation.
Host string
// Repo is the full repository/project name shown to AO users, usually
// "owner/name" for GitHub-issue trackers.
Repo string
// Issue contains the normalized issue facts (state, assignee, title, body).
Issue TrackerIssueObservation
// Comments contains the normalized comments observed on the issue. The
// observer is responsible for windowing/dedup; lifecycle treats every
// entry as a fact about the current snapshot.
Comments []TrackerCommentObservation
// Changed marks which semantic buckets changed compared with the DB snapshot.
Changed TrackerChanged
}
// TrackerChanged marks which semantic state buckets changed in the successful
// poll. The discriminator lets lifecycle skip work cheaply when only one
// bucket moved; today it also lets the reducer fire reactions on the right
// edges (assignee-only change vs comment-only change).
type TrackerChanged struct {
// State is true when Issue.State changed since the last persisted snapshot.
State bool
// Assignee is true when Issue.Assignee changed since the last persisted snapshot.
Assignee bool
// Comments is true when the comment set changed (new comment, edit, or removal).
Comments bool
}
// TrackerIssueObservation carries the normalized issue facts. The field set is
// deliberately the minimum that lifecycle reactions need today; provider
// adapters keep richer per-provider metadata behind their own packages.
type TrackerIssueObservation struct {
// URL is the canonical issue browser URL used as the persistence key.
URL string
// Number is the provider's issue number within the repository/project.
Number int
// State is AO's normalized issue state from domain.NormalizedIssueState
// (open, in_progress, review, done, cancelled).
State domain.NormalizedIssueState
// Title is the provider issue title.
Title string
// Body is the issue description as plain text/markdown.
Body string
// Assignee is the login/identifier of the currently primary assignee, or
// "" when the issue is unassigned. Multi-assignee tracking is not part of
// the lifecycle contract today.
Assignee string
// Author is the login/name of the issue author.
Author string
// Labels is the normalized label set on the issue.
Labels []string
// CreatedAtProvider is the provider's issue creation timestamp.
CreatedAtProvider time.Time
// UpdatedAtProvider is the provider's last issue update timestamp.
UpdatedAtProvider time.Time
// ClosedAtProvider is the provider's close timestamp when the issue is closed.
ClosedAtProvider time.Time
}
// TrackerCommentObservation is one normalized issue comment.
type TrackerCommentObservation struct {
// ID is the provider's stable comment identifier.
ID string
// Author is the provider login/name of the commenter.
Author string
// Body is the comment text.
Body string
// URL is a provider link to the comment.
URL string
// IsBot is true when the provider identifies the author as a bot. The
// lifecycle reducer treats new bot comments as actionable nudges.
IsBot bool
// CreatedAtProvider is the provider's comment creation timestamp.
CreatedAtProvider time.Time
// UpdatedAtProvider is the provider's last comment update timestamp when
// the provider exposes one.
UpdatedAtProvider time.Time
}