Skip to content

feat(backend): wire PR engine into the daemon#87

Open
Pritom14 wants to merge 2 commits into
mainfrom
feat/pr-engine-wiring
Open

feat(backend): wire PR engine into the daemon#87
Pritom14 wants to merge 2 commits into
mainfrom
feat/pr-engine-wiring

Conversation

@Pritom14
Copy link
Copy Markdown
Collaborator

@Pritom14 Pritom14 commented Jun 2, 2026

Closes #86

Summary

The PR observation subsystem (service/pr.Manager, lifecycle/reactions.go, scm/github.Provider) was fully built and unit tested, but it was never wired into the running daemon. The daemon constructed the Lifecycle Manager with a nil messenger plus a noopMessenger, and nothing produced PR observations, so the entire path was dark in production. This PR makes the engine live end to end.

Changes made

  1. Lifecycle nudges over the live messenger (daemon/lifecycle_wiring.go, daemon.go). feat(cli): add minimal ao send #83 (now on main) added the runtime messenger that pastes a message into a session's zellij pane and wired it into startSession for human /send. This PR threads that same messenger into startLifecycle so lifecycle.New(store, messenger) gets a real sender instead of nil, which is what makes PR nudges actually reach the agent's pane. The messenger is built before the LCM so both consumers share one instance.
  2. Branch to PR discovery (scm/github/discover.go). FindPRForBranch(owner, repo, branch) queries the open PR whose head is the session's branch. A found=false result with a nil error is the normal pre-PR state, not a failure.
  3. PR poller (observe/prpoller/). A reaper-style timer (30s). Each tick lists non-terminated sessions that have a branch, resolves owner/repo per project, discovers the PR, observes it, and feeds the observation into the PR service. Per-session errors are logged and skipped so one bad session cannot stall the loop.
  4. Daemon wiring (daemon/pr_wiring.go, daemon/repo_resolver.go, daemon.go). Builds the provider (token chain: AO_GITHUB_TOKEN, then GITHUB_TOKEN, then gh auth token), the PR service over the shared store and LCM, a git-backed owner/repo resolver that parses the project's origin remote, and the poller goroutine with lifecycle-managed shutdown.

Why

Without this, a session could open a PR and have CI fail, yet the agent was never nudged because no component produced observations and the messenger was a no-op. The branch self-discovery approach is used because domain.SessionMetadata already stores Branch, so the poller can find a session's PR via the GitHub API without a separate hook-based capture path.

Graceful degradation: when no GitHub token resolves, the poller is disabled with a warning and the daemon still starts normally.

Flow diagram (textual)

                          LCM (sole writer)
                                |
  prpoller (30s tick)           | store.Upsert + ApplyPRObservation
        |                       v
        | ListAllSessions   SQLiteStore -- DB trigger --> change_log --> CDC --> broadcaster
        v
  for each non-terminated session with a Branch:
        |
        | gitRepoResolver.RepoIdent(project)   (parse origin remote -> owner/repo)
        v
  github.Provider.FindPRForBranch(owner, repo, branch)
        |
        |-- found = false --> skip (no PR yet)
        |
        v  prURL
  github.Provider.Observe(prURL)  -> ports.PRObservation
        |
        v
  pr.Manager.ApplyObservation(sessionID, obs)
        |-- WritePR (persist PR rows)
        v
  lifecycle.Manager.ApplyPRObservation(sessionID, obs)
        |-- CI failing / changes requested / merge conflict -> nudge
        v
  runtimeMessenger.Send(sessionID, message)
        |-- store.GetSession -> RuntimeHandleID
        v
  zellij.Runtime.SendMessage(handle, message)  -> agent's pane

Test plan

  • gofmt -l clean
  • go vet ./... clean
  • go build ./... clean
  • go test -race for prpoller, github scm, pr, lifecycle, daemon, integration (all green)
  • Functional end to end test (internal/integration/prpoller_functional_test.go): drives the wired poller against a fake GitHub with the real github.Provider, a real sqlite store, a real Lifecycle Manager, and the real PR service. Asserts a CI-failing PR flows discovery, observe, persist, derived ci_failed status, and a lifecycle nudge carrying the failing log tail. A second case asserts a branch with no open PR stays quiet.
  • Manual smoke: run daemon with a real GitHub token, open a PR on a session branch, confirm CI-failure nudge reaches the pane

This branch was rebased onto main to pick up #83 (ao send), which independently introduced the runtime messenger. The duplicate messenger this PR originally carried was dropped in favor of #83's, and only the lifecycle-wiring delta (passing the messenger into the LCM) plus the poller wiring remain.

Note: internal/terminal real-zellij test TestSessionStreamsRealZellijPane fails locally on macOS due to the $TMPDIR IPC socket path exceeding zellij's 103-byte limit. It is unrelated to this change (the terminal package is untouched).

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 2, 2026

Greptile Summary

This PR wires the previously-built but dormant PR observation subsystem end-to-end into the running daemon: the lifecycle manager now receives a real runtime messenger instead of nil, and a new 30-second poller discovers each live session's open PR from its branch, observes it, and feeds CI/review state into the existing pr.Managerlifecycle.Manager → nudge chain.

  • observe/prpoller – New timer-driven poller with per-project owner/repo caching within a tick and per-session error isolation so one bad session cannot stall the loop.
  • scm/github/discover.go – New FindPRForBranch method on the existing Provider, returning found=false (nil error) for the pre-PR state of a fresh branch and surfacing network/auth errors so the poller can back off.
  • daemon/pr_wiring.go + repo_resolver.go – Daemon-level assembly: GitHub token chain (AO_GITHUB_TOKENGITHUB_TOKENgh auth token), git-remote-based owner/repo resolver, and graceful degradation (poller skipped with a warning when no token is available).

Confidence Score: 5/5

Safe to merge — wires a previously-built, tested subsystem into the daemon with correct shutdown ordering and graceful token-absent degradation.

The change activates a path that was already fully unit- and integration-tested. Token resolution, per-session error isolation, and shutdown sequencing are all handled correctly. No new defects were found beyond the two diagnostic-quality observations already raised in prior threads.

No files require special attention. The two pre-existing observations in the review thread (prpoller.go log levels, repo_resolver.go stderr capture) are worth tracking but do not block the merge.

Important Files Changed

Filename Overview
backend/internal/observe/prpoller/prpoller.go New poller: clean per-session isolation, per-project caching, and correct context-driven shutdown.
backend/internal/daemon/pr_wiring.go Daemon PR wiring: token-chain resolved correctly, graceful degradation when no token found, correct nil-done guard in Stop().
backend/internal/daemon/daemon.go Correct shutdown ordering (prStack.Stop before lcStack.Stop) and error-path cleanup; messenger correctly instantiated before startLifecycle.
backend/internal/daemon/repo_resolver.go Git-remote URL parser handles https, scp, and ssh://git@ forms; correctly rejects non-GitHub hosts.
backend/internal/adapters/scm/github/discover.go Clean FindPRForBranch implementation; correctly distinguishes found=false/nil-error (no PR yet) from auth/network errors.
backend/internal/daemon/lifecycle_wiring.go Signature updated to accept messenger and pass it to lifecycle.New, replacing the previous nil.
backend/internal/integration/prpoller_functional_test.go End-to-end functional test verifies the full discovery → observe → persist → status → nudge chain against a fake GitHub server.
backend/internal/observe/prpoller/prpoller_test.go Comprehensive unit tests covering caching, skip conditions, error propagation, and list-failure propagation.
backend/internal/adapters/scm/github/discover_test.go Tests cover found, not-found, auth-error, and empty-args cases for FindPRForBranch.
backend/internal/daemon/repo_resolver_test.go parseOwnerRepo table-driven tests cover https, scp, ssh, trailing newline, and non-GitHub rejections.

Sequence Diagram

sequenceDiagram
    participant D as daemon.go
    participant PP as prpoller.Poller
    participant RR as gitRepoResolver
    participant GH as github.Provider
    participant PRM as pr.Manager
    participant LCM as lifecycle.Manager
    participant MSG as runtimeMessenger

    D->>D: startPRPoller: probe token chain
    D->>PP: New + Start(ctx)

    loop every 30s
        PP->>PP: ListAllSessions
        loop per non-terminated session with branch
            PP->>RR: RepoIdent(projectID)
            RR-->>PP: owner, repo
            PP->>GH: FindPRForBranch(owner,repo,branch)
            GH-->>PP: prURL, found
            alt found
                PP->>GH: Observe(prURL)
                GH-->>PP: PRObservation
                PP->>PRM: ApplyObservation(sessionID,obs)
                PRM->>LCM: ApplyPRObservation(sessionID,obs)
                alt nudge condition
                    LCM->>MSG: Send(sessionID,message)
                end
            end
        end
    end

    D->>D: cancel ctx
    D->>PP: prStack.Stop()
    D->>LCM: lcStack.Stop()
Loading

Reviews (2): Last reviewed commit: "test(backend): add end-to-end functional..." | Re-trigger Greptile

Comment on lines +161 to +176
prURL, found, err := p.discoverer.FindPRForBranch(ctx, ident.owner, ident.repo, sess.Metadata.Branch)
if err != nil {
p.logger.Debug("prpoller: PR discovery failed",
"session", sess.ID, "branch", sess.Metadata.Branch, "err", err)
return
}
if !found {
return
}

obs, err := p.observer.Observe(ctx, prURL)
if err != nil {
p.logger.Debug("prpoller: PR observation failed",
"session", sess.ID, "pr", prURL, "err", err)
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Discovery and observation failures are logged at Debug level, so they are invisible under default Info-level production logging. GitHub rate-limit errors (HTTP 429/403), expired tokens, or network failures would all be silently swallowed, making it very difficult to diagnose why agents stop receiving CI nudges in production.

Suggested change
prURL, found, err := p.discoverer.FindPRForBranch(ctx, ident.owner, ident.repo, sess.Metadata.Branch)
if err != nil {
p.logger.Debug("prpoller: PR discovery failed",
"session", sess.ID, "branch", sess.Metadata.Branch, "err", err)
return
}
if !found {
return
}
obs, err := p.observer.Observe(ctx, prURL)
if err != nil {
p.logger.Debug("prpoller: PR observation failed",
"session", sess.ID, "pr", prURL, "err", err)
return
}
prURL, found, err := p.discoverer.FindPRForBranch(ctx, ident.owner, ident.repo, sess.Metadata.Branch)
if err != nil {
p.logger.Warn("prpoller: PR discovery failed",
"session", sess.ID, "branch", sess.Metadata.Branch, "err", err)
return
}
if !found {
return
}
obs, err := p.observer.Observe(ctx, prURL)
if err != nil {
p.logger.Warn("prpoller: PR observation failed",
"session", sess.ID, "pr", prURL, "err", err)
return
}

Comment on lines +49 to +55
func gitOriginURL(ctx context.Context, repoPath string) (string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", repoPath, "remote", "get-url", "origin").Output()
if err != nil {
return "", err
}
return string(out), nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 exec.CommandContext(...).Output() only captures stdout; when git exits non-zero the error is just exit status 128 with no diagnostic text. Capturing ExitError.Stderr would surface the actual git message, making resolver failures much easier to diagnose in production.

Suggested change
func gitOriginURL(ctx context.Context, repoPath string) (string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", repoPath, "remote", "get-url", "origin").Output()
if err != nil {
return "", err
}
return string(out), nil
}
func gitOriginURL(ctx context.Context, repoPath string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
}
return "", err
}
return string(out), nil
}

Pritom14 and others added 2 commits June 2, 2026 22:54
The PR observation subsystem was fully built and unit tested but never
wired into the running daemon: the LCM was constructed with a nil
messenger and a noopMessenger, and nothing produced PR observations.
This makes the engine live end to end via branch self-discovery, with
graceful degradation when no GitHub token is configured.

- Real runtime messenger resolves a session's runtime handle and pastes
  via the zellij runtime, backing both human /send and lifecycle nudges.
- FindPRForBranch discovery primitive on the GitHub provider.
- PR poller that discovers, observes, persists, and drives nudges.
- Daemon wiring: provider + PR service + poller goroutine with
  lifecycle-managed shutdown; disabled with a warning when no token.

Closes #86

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drive the wired poller against a fake GitHub (real github.Provider for
discovery and observation), a real sqlite store, a real Lifecycle Manager,
and the real PR service. Asserts a CI-failing PR flows discovery -> observe
-> persist -> derived session status -> lifecycle nudge, and that a branch
with no open PR stays quiet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire PR engine into the daemon (built but unwired)

2 participants