fix(github): preserve PR watch branch identities (#3649)

This commit is contained in:
José Almeida
2026-09-14 14:59:28 +02:00
committed by GitHub
parent bb784a1d90
commit 0f4bf79b8d
10 changed files with 616 additions and 18 deletions
+4 -6
View File
@@ -83,9 +83,9 @@ type TaskBranchInfo struct {
// TaskBranchProvider lists tasks that should have PR watches and resolves branches.
type TaskBranchProvider interface {
ListTasksNeedingPRWatch(ctx context.Context) ([]TaskBranchInfo, error)
// ResolveBranchForSession returns the current branch for a task+session pair.
// Used to detect branch renames and update stale PR watches.
ResolveBranchForSession(ctx context.Context, taskID, sessionID string) string
// ResolveBranchForWatch resolves a branch within the watch's source session and repository.
// An empty result leaves the watch unchanged.
ResolveBranchForWatch(ctx context.Context, watch *PRWatch) string
}
// Poller runs background loops for PR monitoring and review queue checking.
@@ -460,9 +460,7 @@ func (p *Poller) refreshStaleBranches(ctx context.Context) {
if watch.PRNumber != 0 {
continue // already found a PR, branch is correct
}
currentBranch := p.taskBranchProvider.ResolveBranchForSession(
ctx, watch.TaskID, watch.SessionID,
)
currentBranch := p.taskBranchProvider.ResolveBranchForWatch(ctx, watch)
if currentBranch == "" || currentBranch == watch.Branch {
continue
}
@@ -0,0 +1,126 @@
package github
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
type watchIdentityProvider struct{ targets []TaskBranchInfo }
func (p *watchIdentityProvider) ListTasksNeedingPRWatch(context.Context) ([]TaskBranchInfo, error) {
return p.targets, nil
}
func (p *watchIdentityProvider) ResolveBranchForWatch(_ context.Context, w *PRWatch) string {
for _, target := range p.targets {
if target.SessionID == w.SessionID && target.RepositoryID == w.RepositoryID && target.Branch == w.Branch {
return target.Branch
}
}
return ""
}
// @covers AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.1
// @covers AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4
func TestReconcileWatches_PreservesBranchTargetsAcrossCycles(t *testing.T) {
poller, svc, client, store := setupBatchedPollerTest(t)
ctx := context.Background()
seedTask(t, store, "owner", false)
provider := &watchIdentityProvider{targets: []TaskBranchInfo{
{WorkspaceID: testWorkspaceID, TaskID: "owner", SessionID: "member-session", RepositoryID: "repo1", Owner: "o", Repo: "r", Branch: "primary"},
{WorkspaceID: testWorkspaceID, TaskID: "owner", SessionID: "member-session", RepositoryID: "repo1", Owner: "o", Repo: "r", Branch: "secondary"},
{WorkspaceID: testWorkspaceID, TaskID: "owner", SessionID: "member-session", RepositoryID: "repo2", Owner: "o", Repo: "other", Branch: "other-branch"},
}}
poller.SetTaskBranchProvider(provider)
poller.reconcileWatches(ctx)
initial, err := store.ListActivePRWatches(ctx)
if err != nil {
t.Fatal(err)
}
if len(initial) != 3 {
t.Fatalf("initial watches = %d, want 3", len(initial))
}
// A numbered sibling must survive while the other branches still search.
for _, w := range initial {
if w.Branch == "primary" {
if err := store.UpdatePRWatchPRNumber(ctx, w.ID, 99); err != nil {
t.Fatal(err)
}
}
}
initial, err = store.ListActivePRWatches(ctx)
if err != nil {
t.Fatal(err)
}
assertWatchReconciliationStable(t, poller, store, initial)
assertSecondaryWatchDiscovery(t, svc, client, store)
}
func assertWatchReconciliationStable(t *testing.T, poller *Poller, store *Store, initial []*PRWatch) {
t.Helper()
ctx := context.Background()
for range 3 {
poller.reconcileWatches(ctx)
current, err := store.ListActivePRWatches(ctx)
if err != nil {
t.Fatal(err)
}
if len(current) != len(initial) {
t.Fatalf("watch count = %d, want %d", len(current), len(initial))
}
for _, before := range initial {
after, err := store.GetPRWatchBySessionRepoAndBranch(ctx, before.SessionID, before.RepositoryID, before.Branch)
if err != nil {
t.Fatal(err)
}
if after == nil || after.ID != before.ID || !after.UpdatedAt.Equal(before.UpdatedAt) {
t.Fatalf("watch %s recreated or rewritten: before=%+v after=%+v", before.Branch, before, after)
}
if before.Branch == "primary" && after.PRNumber != 99 {
t.Fatal("numbered sibling changed")
}
}
}
}
func assertSecondaryWatchDiscovery(t *testing.T, svc *Service, client *graphQLMockClient, store *Store) {
t.Helper()
ctx := context.Background()
secondary, err := store.GetPRWatchBySessionRepoAndBranch(ctx, "member-session", "repo1", "secondary")
if err != nil {
t.Fatal(err)
}
updated := make(chan *TaskPR, 1)
sub, err := svc.eventBus.Subscribe(events.GitHubTaskPRUpdated, func(_ context.Context, event *bus.Event) error {
if pr, ok := event.Data.(*TaskPR); ok {
updated <- pr
}
return nil
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
client.branchResponses = []string{branchAliasResponse(t, 1, map[int]string{0: openPRNode(7, "secondary", "alice")})}
if _, err := svc.SyncWorkspaceWatchesBatched(ctx, testWorkspaceID, []*PRWatch{secondary}); err != nil {
t.Fatal(err)
}
associated, err := store.GetTaskPR(ctx, "owner")
if err != nil {
t.Fatal(err)
}
if associated == nil || associated.PRNumber != 7 || associated.RepositoryID != "repo1" {
t.Fatalf("association = %+v", associated)
}
select {
case event := <-updated:
if event.TaskID != "owner" || event.PRNumber != 7 || event.RepositoryID != "repo1" {
t.Fatalf("event = %+v", event)
}
case <-time.After(time.Second):
t.Fatal("missing task PR event")
}
}
+2 -2
View File
@@ -248,9 +248,9 @@ func (m *mockTaskBranchProvider) ListTasksNeedingPRWatch(_ context.Context) ([]T
return m.tasks, m.err
}
func (m *mockTaskBranchProvider) ResolveBranchForSession(_ context.Context, _, sessionID string) string {
func (m *mockTaskBranchProvider) ResolveBranchForWatch(_ context.Context, watch *PRWatch) string {
if m.branches != nil {
return m.branches[sessionID]
return m.branches[watch.SessionID]
}
return ""
}
@@ -1444,12 +1444,6 @@ func (s *Service) ListTasksNeedingPRWatch(ctx context.Context) ([]github.TaskBra
return s.buildTaskBranchList(ctx, store)
}
// ResolveBranchForSession returns the current branch for a task+session.
// This is used by the poller to detect branch renames on existing PR watches.
func (s *Service) ResolveBranchForSession(ctx context.Context, taskID, sessionID string) string {
return s.resolvePRWatchBranch(ctx, taskID, sessionID, "")
}
// buildTaskBranchList walks sessions × their repositories and emits one
// TaskBranchInfo per (session, repository) that doesn't already have a PR
// watch. Multi-repo: previously dedup was keyed by sessionID, which silently
@@ -1472,11 +1466,15 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g
watchedKeys := s.buildWatchedSessionRepoSet(ctx)
var result []github.TaskBranchInfo
seenSessions := make(map[string]struct{})
for _, sess := range sessions {
// Pass sess.Branch as fallback for the primary repo (legacy callers
// stored the worktree branch on SessionBranchInfo); per-repo branches
// come from session.Worktrees inside resolveSessionWatchTargets.
targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, sess.Branch)
if _, seen := seenSessions[sess.SessionID]; seen {
continue
}
seenSessions[sess.SessionID] = struct{}{}
// Inventory rows can carry any repository's branch. Resolve each session
// once from its per-repository targets, as watch refresh does.
targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, "")
for _, t := range targets {
if watchedKeys[watchedSessionRepoKey(sess.SessionID, t.RepositoryID, t.Branch)] {
continue
@@ -0,0 +1,40 @@
package orchestrator
import (
"context"
"github.com/kandev/kandev/internal/github"
)
// ResolveBranchForWatch keeps checkout identity scoped to the source session.
// The watch's attributed task can be a group owner with different branches.
func (s *Service) ResolveBranchForWatch(ctx context.Context, watch *github.PRWatch) string {
if watch == nil || watch.SessionID == "" || watch.RepositoryID == "" {
return ""
}
session, err := s.repo.GetTaskSession(ctx, watch.SessionID)
if err != nil || session == nil {
return ""
}
targets := s.resolveSessionWatchTargets(ctx, session.TaskID, session.ID, "")
return matchingWatchBranch(targets, watch.RepositoryID, watch.Branch)
}
func matchingWatchBranch(targets []sessionWatchTarget, repositoryID, current string) string {
branches := make(map[string]struct{})
for _, target := range targets {
if target.RepositoryID != repositoryID || target.Branch == "" {
continue
}
if target.Branch == current {
return current
}
branches[target.Branch] = struct{}{}
}
if len(branches) == 1 {
for branch := range branches {
return branch
}
}
return ""
}
@@ -0,0 +1,151 @@
package orchestrator
import (
"context"
"fmt"
"testing"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
)
// @covers AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4
func TestResolvePRWatchBranchForWatch(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedGitHubPushAssocFixture(t, repo, "primary")
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo2", WorkspaceID: "ws1", Name: "secondary", Provider: "github", ProviderOwner: "myorg", ProviderName: "secondary"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr2", TaskID: "t1", RepositoryID: "repo2", CheckoutBranch: "secondary", Position: 1}); err != nil {
t.Fatal(err)
}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
watch := &github.PRWatch{TaskID: "t1", SessionID: "s1", RepositoryID: "repo2", Branch: "secondary"}
if got := svc.ResolveBranchForWatch(ctx, nil); got != "" {
t.Fatalf("nil watch resolved to %q", got)
}
if got := svc.ResolveBranchForWatch(ctx, &github.PRWatch{RepositoryID: "repo1"}); got != "" {
t.Fatalf("missing session resolved to %q", got)
}
got := svc.ResolveBranchForWatch(ctx, watch)
if got != watch.Branch {
t.Fatalf("secondary watch resolved to %q, want %q", got, watch.Branch)
}
}
// @covers AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4
func TestResolvePRWatchBranchForWatch_SourceIdentity(t *testing.T) {
for _, tt := range []struct {
name, session, repository, branch, want string
branches []string
cancelled bool
}{
{name: "configured checkout wins", session: "s1", repository: "repo1", branch: "old", want: "primary", branches: []string{"synthetic-review"}},
{name: "exact sibling survives", session: "s1", repository: "repo1", branch: "secondary", want: "secondary", branches: []string{"primary", "secondary"}},
{name: "ambiguous rename", session: "s1", repository: "repo1", branch: "old", branches: []string{"first", "second"}},
{name: "empty sibling is not candidate", session: "s1", repository: "repo1", branch: "old", want: "valid", branches: []string{"", "valid"}},
{name: "duplicate branches are one candidate", session: "s1", repository: "repo1", branch: "old", want: "valid", branches: []string{"valid", "valid"}},
{name: "missing repository", session: "s1", repository: "missing", branch: "old", branches: []string{"primary"}},
{name: "missing session", session: "missing", repository: "repo1", branch: "old"},
{name: "missing repository identity", session: "s1", branch: "old"},
{name: "cancelled read", session: "s1", repository: "repo1", branch: "old", cancelled: true},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedGitHubPushAssocFixture(t, repo, "primary")
owner, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatal(err)
}
owner.ID = "attributed-group-owner"
if err := repo.CreateTask(ctx, owner); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "owner-tr", TaskID: "attributed-group-owner", RepositoryID: "repo1", CheckoutBranch: "owner-primary"}); err != nil {
t.Fatal(err)
}
repos := make([]*models.TaskEnvironmentRepo, 0, len(tt.branches))
for i, branch := range tt.branches {
repos = append(repos, &models.TaskEnvironmentRepo{ID: fmt.Sprintf("wt%d", i), RepositoryID: "repo1", WorktreeID: fmt.Sprintf("tree%d", i), BranchSlug: fmt.Sprintf("branch%d", i), WorktreeBranch: branch, Status: "active", Position: i})
}
if len(repos) > 0 {
attachWatchTestEnvironment(t, repo, repos)
}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
if tt.cancelled {
cancelled, cancel := context.WithCancel(ctx)
cancel()
ctx = cancelled
}
watch := &github.PRWatch{TaskID: "attributed-group-owner", SessionID: tt.session, RepositoryID: tt.repository, Branch: tt.branch}
got := svc.ResolveBranchForWatch(ctx, watch)
if got != tt.want {
t.Fatalf("branch = %q, want %q", got, tt.want)
}
})
}
}
func attachWatchTestEnvironment(t *testing.T, repo *sqliterepo.Repository, repos []*models.TaskEnvironmentRepo) {
t.Helper()
ctx := context.Background()
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env1", TaskID: "t1", ExecutorType: "worktree", WorkspacePath: t.TempDir(), Status: models.TaskEnvironmentStatusReady, Repos: repos}); err != nil {
t.Fatal(err)
}
session, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatal(err)
}
session.TaskEnvironmentID = "env1"
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatal(err)
}
}
// @covers AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4
func TestResolvePRWatchBranchForWatch_CreationUsesSameTargets(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
seedGitHubPushAssocFixture(t, repo, "")
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo2", WorkspaceID: "ws1", Name: "secondary", Provider: "github", ProviderOwner: "myorg", ProviderName: "secondary"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr2", TaskID: "t1", RepositoryID: "repo2", Position: 1}); err != nil {
t.Fatal(err)
}
attachWatchTestEnvironment(t, repo, []*models.TaskEnvironmentRepo{
{ID: "wt1", RepositoryID: "repo1", WorktreeID: "tree1", WorktreeBranch: "primary", Status: "active"},
{ID: "wt2", RepositoryID: "repo2", WorktreeID: "tree2", WorktreeBranch: "secondary", Status: "active", Position: 1},
})
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.SetGitHubService(&mockGitHubService{})
targets, err := svc.ListTasksNeedingPRWatch(ctx)
if err != nil {
t.Fatal(err)
}
seen := make(map[string]bool)
for _, target := range targets {
want := map[string]string{"repo1": "primary", "repo2": "secondary"}[target.RepositoryID]
if target.Branch != want {
t.Fatalf("creation target = %+v, want branch %q", target, want)
}
key := target.SessionID + "/" + target.RepositoryID + "/" + target.Branch
if seen[key] {
t.Fatalf("duplicate creation target %s", key)
}
seen[key] = true
watch := &github.PRWatch{SessionID: target.SessionID, TaskID: target.TaskID, RepositoryID: target.RepositoryID, Branch: target.Branch}
if got := svc.ResolveBranchForWatch(ctx, watch); got != want {
t.Fatalf("refresh = %q, want %q", got, want)
}
}
if len(targets) != 2 {
t.Fatalf("targets = %d, want 2", len(targets))
}
}
@@ -0,0 +1,114 @@
---
created: 2026-09-13
status: done
requirements:
- REQ-INTEGRATIONS-GITHUB-PR-DISCOVERY-001
system_design:
- ../../specs/integrations/system-design/github-pr-discovery-health.md
legacy_specs: []
---
# Implementation Plan: Stable GitHub PR watch reconciliation
## Overview
Repair repeated branch rewrites and watch recreation in one sequential backend
work order. Extend the existing discovery requirement with AC .4 and its design;
ACs .1-.3 continue to govern association, identity, and unlink preservation.
The completed discovery-health package remains historical evidence and is not
reopened: this package changes target reconciliation only.
## Scope
### In scope
- Watch-aware branch resolution shared with creation's target source.
- Multi-repository, multi-branch, and redirected-owner regression coverage.
- Preserve genuine renames and existing association safety.
### Out of scope
- Main-instance mutation, restarts, deployment, database repair, or delegation.
- GitLab, provider quotas, new polling loops, UI changes, or new public APIs.
- Attribution of the user's original missing PR, which was not identified.
## Evidence and root cause
Main instance build `509a1bb9b` repeatedly logged branch changes followed by
recreation on the previous branch every minute. For example, on September 13
at 17:02:54 Lisbon time, one session's watches changed to the primary
branch and recreated their former branch for two repositories. Retained September 11-13 logs contained over 110,000 occurrences
of each operation. GitHub status was healthy; rate limits were not implicated.
`refreshStaleBranches` uses session-only resolution through the attributed
task's primary repository. Creation uses source-session repository/worktree
targets and redirects association ownership separately. Refresh therefore
collapses legitimate targets; reconciliation sees them missing and recreates
them. Collision deletion can repeat the cycle without a SQL error.
## Technical approach
Follow the watch target reconciliation section of the discovery system design.
Update `TaskBranchProvider`, its orchestrator implementation, and test doubles
together. Pass repository and existing branch identity to the resolver; derive
source task identity from the session. Preserve exact matches before considering
a unique same-repository replacement. Keep existing atomic update guards.
Extract focused resolver helpers/tests if needed to respect file limits.
The creation/refresh regression also reproduced inventory expansion assigning
one repository's branch to another. Deduplicate session inventory rows and
resolve all targets without that unqualified branch fallback.
## Tests
- AC .4: `TestResolvePRWatchBranchForWatch` in new orchestrator
`event_handlers_github_watch_reconciliation_test.go`, using real target
resolution with multiple repositories, multiple branches, group ownership,
missing data, ambiguous replacements, and genuine single-branch renames.
- AC .4: `TestReconcileWatches_PreservesBranchTargetsAcrossCycles` in new GitHub
`poller_branch_reconciliation_test.go`. With real SQLite watch storage, run
at least three reconciliation cycles and assert unchanged IDs, branches,
row counts, and no repeated create/update calls for unchanged targets.
- ACs .1-.3: retain numbered-watch, collision, detached-association, and
multi-branch discovery tests in the existing GitHub suite.
## End-to-end evidence
Backend integration coverage carries a secondary-branch watch through
reconciliation, canned provider discovery, persisted task-PR association, and
the task-PR event. Include a numbered sibling to prove mixed-state preservation.
Use existing GraphQL/poller fixtures; do not call GitHub or the main instance.
No browser test is needed because no rendering contract changes.
## Work orders
- [x] [Task 01: Preserve watch branch identity](task-01-preserve-watch-identity.md)
## Verification results
Design validation passed on 2026-09-13:
- `python3 scripts/list-docs.py validate`: 267 decisions and 868 specifications validated.
- `python3 scripts/lint-spec-files.py --all`: passed.
- `git diff --check`: passed.
- Scoped `git status --short`: both specification edits and the new package present.
Focused regressions passed after reproducing both defects. The full GitHub
suite passed in 284.480s. The full orchestrator suite passed in 202.462s with
both temporary-directory variables pointing at owned tmpfs storage, following
the documented disk-I/O timeout. The targeted race check passed in 1.293s. Exact commands and results are
recorded in the completed work order. Public documentation remains
unchanged because this package records repair intent without new user operations.
## Risks
- Association owner and source session task differ in shared workspaces.
- Picking the first branch would hide ambiguity and repeat the defect.
- Synthetic review worktrees must retain configured checkout precedence.
- Existing corrupted watches may need one normal convergence cycle; no bulk
repair is included. Explicit unlink and numbered-watch state must survive.
## Related contracts
- [Discovery requirements](../../specs/integrations/requirements/github-pr-discovery-health.md)
- [Multi-branch decision](../../decisions/0013-multi-branch-tasks.md)
- [Earlier discovery package](../github-pr-discovery-health/plan.md)
@@ -0,0 +1,130 @@
---
id: "01-preserve-watch-identity"
title: "Preserve watch branch identity"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-INTEGRATIONS-GITHUB-PR-DISCOVERY-001
acceptance_criteria:
- AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.1
- AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.2
- AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.3
- AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4
system_design:
- ../../specs/integrations/system-design/github-pr-discovery-health.md
---
# Task 01: Preserve watch branch identity
## Summary
Make refresh resolve the same per-session repository/branch targets as creation.
Prove repeated reconciliation preserves sibling targets and subsequent discovery.
## In scope
- Update the provider interface, orchestrator resolver, poller call, and mocks.
- Add the resolver and repeated-cycle regressions named in the plan.
- Prove secondary-branch discovery persists and publishes the owning task PR.
## Out of scope
Live-instance changes, new schema, frontend changes, GitLab, rate-limit policy,
bulk cleanup, and changes to workspace-group association ownership.
## Acceptance
1. Three unchanged reconciliation cycles preserve watch IDs and repository/branch
targets without recreation, including redirected owners and multiple branches
on the same repository. At least one searching and one numbered watch coexist.
2. Exact matches survive; unique same-repository renames update; missing/error
and ambiguous target resolution leaves state intact. No primary-repo fallback.
3. An eligible PR on a secondary branch is linked after reconciliation with
correct repository identity and task event; numbered links and unlink
tombstones remain protected by existing writers and regression coverage.
## Verification
Start with a failing regression using the existing production contract before
changing it. Record the expected wrong-branch or replaced-watch failure, then
implement and run these commands from the repository root:
```bash
(cd apps/backend && go test ./internal/github ./internal/orchestrator -run 'Test(ReconcileWatches_PreservesBranchTargetsAcrossCycles|ResolvePRWatchBranchForWatch)' -count=1)
(cd apps/backend && go test ./internal/github -count=1)
(cd apps/backend && go test ./internal/orchestrator -count=1 -timeout=30m)
(cd apps/backend && go test -race ./internal/github -run 'Test(ReconcileWatches|RefreshStaleBranches)' -count=1)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
On the development host, the combined package run passed GitHub but exhausted
Go's default ten-minute timeout while the orchestrator suite was closing a
SQLite fixture. The final retry uses an owned temporary directory on the existing
`/dev/shm` tmpfs via both `TMPDIR` and `GOTMPDIR`. Go 1.26's `t.TempDir()`
prefers `GOTMPDIR`; an intermediate retry that left it at `/tmp` was stopped
before correcting that setting. The owned directory is removed after the run. No mount, live database, or instance
setting changes are involved. Use normal temporary storage on hosts without
this disk-I/O constraint.
## Files likely touched
- `apps/backend/internal/github/poller.go`
- `apps/backend/internal/github/poller_test.go`
- `apps/backend/internal/github/poller_branch_reconciliation_test.go` (new)
- `apps/backend/internal/orchestrator/event_handlers_github.go`
- `apps/backend/internal/orchestrator/event_handlers_github_watch_reconciliation.go` (new)
- `apps/backend/internal/orchestrator/event_handlers_github_watch_reconciliation_test.go` (new)
- Other compile-time `TaskBranchProvider` test doubles found by symbol search.
## Dependencies
None. Implementation was explicitly authorized after the design-package handoff.
## Risks
See the plan. Reuse source-session target resolution and existing storage guards;
do not infer a rename from an arbitrary sibling or hide read errors as success.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/integrations/requirements/github-pr-discovery-health.md)
- [Design](../../specs/integrations/system-design/github-pr-discovery-health.md), watch target reconciliation.
- Existing `TestRefreshStaleBranches_*`, `TestListTasksNeedingPRWatch`, and
`TestEnsureSessionPRWatchRedirectsToWorkspaceGroupOwner` fixtures.
- Plan evidence and backend `AGENTS.md`.
## Results
Completed on 2026-09-13.
- Red: the original resolver selected `primary` for a secondary-repository watch;
repeated reconciliation grew three watches to four. Creation/refresh coverage
also reproduced a primary repository receiving the secondary branch.
- Focused `go test ./internal/github ./internal/orchestrator -run
'Test(ReconcileWatches_PreservesBranchTargetsAcrossCycles|ResolvePRWatchBranchForWatch)'
-count=1`: passed both packages (GitHub 0.031s; orchestrator 79.458s).
- Full GitHub suite: passed (284.480s), as the GitHub component of the original
combined package command.
- Full orchestrator suite with `-count=1 -timeout=30m`: passed (202.462s) using
owned tmpfs scratch after the infrastructure timeout described above.
- `go test -race ./internal/github -run
'Test(ReconcileWatches|RefreshStaleBranches)' -count=1`: passed (1.293s),
using owned tmpfs scratch.
- `python3 scripts/list-docs.py validate`: passed, 267 decisions and 868 specifications.
- `python3 scripts/lint-spec-files.py --all`: passed.
- `git diff --check`: passed.
The source session and per-repository targets now govern both refresh and
creation. Existing numbered-watch and unlink guards remain unchanged. New
backend integration coverage proves secondary-PR persistence and task events.
Public docs need no change because the repair restores existing behavior.
No application restart, live database repair, deployment, or delegation occurred.
Owned tmpfs scratch directories were removed after their test runs.
@@ -30,6 +30,11 @@ and provider status. Task and UI systems consume its results.
- **AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.3:** A rejected provider query shall
not be treated as a successful search with no PR. Existing associations and
explicit unlink decisions shall remain intact.
- **AC-INTEGRATIONS-GITHUB-PR-DISCOVERY-001.4:** For every unchanged attached
repository and branch, repeated watch reconciliation shall preserve its
discovery target independently of sibling branches and task-group ownership.
A confirmed rename may update that target; missing or ambiguous branch
information shall not redirect it to a sibling target.
### REQ-INTEGRATIONS-GITHUB-PR-DISCOVERY-HEALTH-001: Explain discovery failures
@@ -74,3 +79,4 @@ runtime status, not a permanent incident archive.
- [Frontend synchronization coordination](github-task-pr-sync-coordination.md)
- [System design](../system-design/github-pr-discovery-health.md)
- [Implementation package](../../../plans/github-pr-discovery-health/plan.md)
- [Watch reconciliation repair](../../../plans/github-pr-watch-reconciliation/plan.md)
@@ -38,6 +38,41 @@ field contract, in addition to exercising response conversion and persistence.
Record the upstream schema reference in the fixture. A canned response that
accepts any query cannot detect this defect.
## Watch target reconciliation
Discovery AC .4 uses the same session repository/worktree target resolution
for watch creation and refresh. In `internal/github/poller.go`,
`TaskBranchProvider.ResolveBranchForWatch` carries session, repository, and
current branch identity. The orchestrator implementation lives in
`event_handlers_github_watch_reconciliation.go`; the poller does not choose a
task's primary branch for every watch.
The session inventory can contain one row per distinct worktree branch without
repository identity. Expand each session once and do not pass an inventory
row's branch as the primary repository fallback; load branches from the
session's per-repository worktrees instead.
Resolve targets from the source session's task through
`resolveSessionWatchTargets`, matching creation's fallback and worktree rules.
`watch.TaskID` may name a workspace-group owner and remains the association
destination, not a substitute source of checkout identity. Reuse target
resolution without invoking effective-owner redirection for branch selection.
Keep an exact repository/branch match. Only select a replacement when there
is exactly one distinct branch target for that repository and the current
branch is absent. Missing session or repository identity, lookup failure, no
matching targets, or multiple unmatched candidates leaves the watch unchanged.
Never use a sibling repository as a fallback. Preserve single-repository
checkout-branch precedence and real unambiguous rename handling.
Keep the existing `pr_number = 0` atomic writer and collision handling.
Numbered watches, associations, detached tombstones, and attribution remain
unchanged. No migration or live database cleanup is required. Reconciliation
must reach a stable set of watch IDs and branches across repeated cycles;
tests include mixed numbered/searching watches and multi-branch sessions.
Delivery: [watch reconciliation repair](../../../plans/github-pr-watch-reconciliation/plan.md).
## Failure state and admission
Add a service-owned runtime discovery-health component. Key each target by