Merged
Fence obsolete repository-document source callbacks #172
urandonAdd on 27 days ago
Fence obsolete repository-document source callbacks #172
Merged
urandonAdd on 27 days ago
Total: 7 files changed+289-32
@@ -99,6 +99,15 @@ gitcode-mcp repo-docs rebind --repo owner/repo --registration-id REG \
99 --source-registration-generation 1 --repository-path /new/worktree99 --source-registration-generation 1 --repository-path /new/worktree
100```100```
101 101 
102+Rebind fences the previous source generation. A late reconcile completion or
103+scheduling failure from that old registration/source/path cannot change the
104+replacement's state, inspection history, retry/error state, active-job links or
105+polling timestamps. It returns the current sanitized authority without writing
106+the registry. Only a matching active job intent can mark the current source
107+`indexing`; current-generation failures still apply normally. Registry restart
108+preserves that authority through the existing exact source-id redirects. This
109+is shared by CLI, MCP and Admin status, not an observation-side repair.
110+ 
102Public status exposes only opaque Git111Public status exposes only opaque Git
103store/worktree refs. The coordinator then polls committed `HEAD` and policy112store/worktree refs. The coordinator then polls committed `HEAD` and policy
104identity every minute and coalesces a new immutable-set job when either113identity every minute and coalesces a new immutable-set job when either
@@ -167,10 +167,14 @@ survive strict replay; paths, source ids, content and raw safe-looking error
167classes remain excluded. Registration and source captures share one bounded167classes remain excluded. Registration and source captures share one bounded
168operation-local buffer and publication boundary.168operation-local buffer and publication boundary.
169Actual queued-job/rebind/fence and reconcile-snapshot/late-scheduling-failure169Actual queued-job/rebind/fence and reconcile-snapshot/late-scheduling-failure
170-fixtures pair obsolete owners with current-generation controls. They preserve170+fixtures pair obsolete owners with current-generation controls. Obsolete
171-existing execution results while requiring exactly one count-only loss for an171+completion and failure callbacks must preserve the entire replacement authority
172-obsolete changed pair, one confirmed source capture for a current pair, and no172+and scheduling state, perform zero registry writes, and manufacture neither a
173-extra boundary on unchanged replay. The held generation-fence test waits for an173+transition nor diagnostic loss. Current controls still apply normally. Restart
174+readback qualifies the exact existing source redirect and retains every other
175+source field. A deterministic registration/cache/repository/source/path/profile
176+mismatch matrix and active-job intent controls fence unrelated work. The held
177+generation-fence test waits for an
174accepted FIFO collector marker after registry save/unlock before asserting no178accepted FIFO collector marker after registry save/unlock before asserting no
175early maintenance publication; releasing the real fence conserves the transition.179early maintenance publication; releasing the real fence conserves the transition.
176Identity-resolution fixtures apply real legacy registry conflicts and physical180Identity-resolution fixtures apply real legacy registry conflicts and physical
@@ -2826,16 +2826,27 @@ func repositoryDocsRetryReady(state *RepositoryDocsMaintenanceState, now time.Ti
2826 2826 
2827func (m *MaintenanceManager) finishReconcileEntry(ctx context.Context, registrationID string, snapshot MaintenanceEntry, contentGeneration, covered int64, ragStatus, namespaceID string, frontiers []cache.MaintenanceFrontier, activeSync, activeRAG, activeRepositoryDocs Job, now time.Time) MaintenanceEntry {2827func (m *MaintenanceManager) finishReconcileEntry(ctx context.Context, registrationID string, snapshot MaintenanceEntry, contentGeneration, covered int64, ragStatus, namespaceID string, frontiers []cache.MaintenanceFrontier, activeSync, activeRAG, activeRepositoryDocs Job, now time.Time) MaintenanceEntry {
2828 m.mu.Lock()2828 m.mu.Lock()
2829+ defer m.mu.Unlock()
2829 entry := m.entries[registrationID]2830 entry := m.entries[registrationID]
2831+ if entry == nil {
2832+ return MaintenanceEntry{}
2833+ }
2834+ _, currentEntry := m.reconcileAuthorityLocked(&snapshot)
2835+ _, authoritySource, currentSourceAuthority := m.inspectionAuthorityLocked(&snapshot)
2836+ if registrationID != snapshot.RegistrationID || !currentEntry || snapshot.RepositoryDocs != nil && !currentSourceAuthority {
2837+ return cloneMaintenanceEntry(entry)
2838+ }
2839+ if activeRepositoryDocs.ID != "" && !repositoryDocsJobMatchesSnapshot(activeRepositoryDocs, snapshot) {
2840+ activeRepositoryDocs = Job{}
2841+ }
2830 beforeObservation := maintenanceObservedState(entry)2842 beforeObservation := maintenanceObservedState(entry)
2831- observedSource := m.sources[registrationID][snapshot.repositorySourceID]2843+ observedSource := authoritySource
2832 beforeSourceObservation := maintenanceObservedSourceState(observedSource)2844 beforeSourceObservation := maintenanceObservedSourceState(observedSource)
2833 entry.ContentGeneration = contentGeneration2845 entry.ContentGeneration = contentGeneration
2834 entry.CoveredGeneration = covered2846 entry.CoveredGeneration = covered
2835 entry.RAGStatus = ragStatus2847 entry.RAGStatus = ragStatus
2836 entry.SyncStage = snapshot.SyncStage2848 entry.SyncStage = snapshot.SyncStage
2837 entry.RAGStage = snapshot.RAGStage2849 entry.RAGStage = snapshot.RAGStage
2838- _, authoritySource, currentSourceAuthority := m.inspectionAuthorityLocked(&snapshot)
2839 if currentSourceAuthority {2850 if currentSourceAuthority {
2840 // Inspection is separately confirmed at its actual completion. A later2851 // Inspection is separately confirmed at its actual completion. A later
2841 // stale finish must not overwrite that transaction or bypass its failure.2852 // stale finish must not overwrite that transaction or bypass its failure.
@@ -2875,13 +2886,16 @@ func (m *MaintenanceManager) finishReconcileEntry(ctx context.Context, registrat
2875 entry.NextReconcileAt = now.Add(time.Minute)2886 entry.NextReconcileAt = now.Add(time.Minute)
2876 persisted := m.saveLocked() == nil2887 persisted := m.saveLocked() == nil
2877 captureMaintenanceObservation(ctx, beforeObservation, entry, persisted)2888 captureMaintenanceObservation(ctx, beforeObservation, entry, persisted)
2878- // An obsolete callback may retain an existing execution-state mutation. Do2889+ captureMaintenanceSourceObservation(ctx, beforeSourceObservation, entry, observedSource, persisted && currentSourceAuthority)
2879- // not certify it as a current source observation: conserve the changed pair2890+ return cloneMaintenanceEntry(entry)
2880- // as unconfirmed loss without altering execution or persistence behavior.2891+}
2881- captureMaintenanceSourceObservation(ctx, beforeSourceObservation, entry, observedSource, persisted && currentSourceAuthority && observedSource == authoritySource)2892+ 
2882- updated := cloneMaintenanceEntry(entry)2893+func repositoryDocsJobMatchesSnapshot(job Job, snapshot MaintenanceEntry) bool {
2883- m.mu.Unlock()2894+ state := snapshot.RepositoryDocs
2884- return updated2895+ return state != nil && job.Type == RepositoryDocsIndexJobType && jobActiveStatus(job.Status) &&
2896+ job.CacheUUID == snapshot.CacheUUID && job.RepoID == snapshot.RepoID &&
2897+ job.RegistrationID == snapshot.RegistrationID && job.SourceRegistrationID == state.SourceRegistrationID &&
2898+ job.SourceRegistrationGeneration == state.SourceRegistrationGeneration
2885}2899}
2886 2900 
2887func (m *MaintenanceManager) reconcileRepositoryDocsState(ctx context.Context, store *cache.SQLiteStore, entry *MaintenanceEntry, now time.Time) bool {2901func (m *MaintenanceManager) reconcileRepositoryDocsState(ctx context.Context, store *cache.SQLiteStore, entry *MaintenanceEntry, now time.Time) bool {
@@ -2962,11 +2976,11 @@ func (m *MaintenanceManager) updateRepositoryDocsFailure(ctx context.Context, id
2962 return MaintenanceEntry{}2976 return MaintenanceEntry{}
2963 }2977 }
2964 source := m.sources[id][strings.TrimSpace(sourceID)]2978 source := m.sources[id][strings.TrimSpace(sourceID)]
2965- if source == nil {2979+ _, authoritySource, currentSourceAuthority := m.inspectionAuthorityLocked(&expected)
2980+ if id != expected.RegistrationID || !currentSourceAuthority || source == nil || source != authoritySource {
2966 return cloneMaintenanceEntry(entry)2981 return cloneMaintenanceEntry(entry)
2967 }2982 }
2968 beforeObservation := maintenanceObservedSourceState(source)2983 beforeObservation := maintenanceObservedSourceState(source)
2969- _, authoritySource, currentSourceAuthority := m.inspectionAuthorityLocked(&expected)
2970 source.State.State = "degraded"2984 source.State.State = "degraded"
2971 source.State.LastErrorClass = class2985 source.State.LastErrorClass = class
2972 source.State.LastError = message2986 source.State.LastError = message
@@ -2976,7 +2990,7 @@ func (m *MaintenanceManager) updateRepositoryDocsFailure(ctx context.Context, id
2976 entry.LastReconciledAt = m.now()2990 entry.LastReconciledAt = m.now()
2977 entry.NextReconcileAt = entry.LastReconciledAt.Add(time.Minute)2991 entry.NextReconcileAt = entry.LastReconciledAt.Add(time.Minute)
2978 persisted := m.saveLocked() == nil2992 persisted := m.saveLocked() == nil
2979- captureMaintenanceSourceObservation(ctx, beforeObservation, entry, source, persisted && currentSourceAuthority && source == authoritySource)2993+ captureMaintenanceSourceObservation(ctx, beforeObservation, entry, source, persisted)
2980 return cloneMaintenanceEntry(entry)2994 return cloneMaintenanceEntry(entry)
2981}2995}
2982 2996 
@@ -112,7 +112,8 @@ func captureMaintenanceObservation(ctx context.Context, before maintenanceObserv
112}112}
113 113 
114// Source confirmation requires both the existing save and current originating114// Source confirmation requires both the existing save and current originating
115-// authority. Retained stale-owner changes are count-only loss, not certification.115+// authority. Failed retained writes are count-only loss, not certification;
116+// rejected obsolete callbacks mutate nothing and contribute neither.
116func captureMaintenanceSourceObservation(ctx context.Context, before maintenanceObservationState, entry *MaintenanceEntry, source *repositoryDocsRegisteredSource, confirmed bool) {117func captureMaintenanceSourceObservation(ctx context.Context, before maintenanceObservationState, entry *MaintenanceEntry, source *repositoryDocsRegisteredSource, confirmed bool) {
117 if source == nil {118 if source == nil {
118 return119 return
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "reflect"
9 "strings"10 "strings"
10 "sync"11 "sync"
11 "sync/atomic"12 "sync/atomic"
@@ -107,7 +108,9 @@ func TestMaintenanceSourceObservationActualInspectionFailureRecovery(t *testing.
107 at = at.Add(time.Minute)108 at = at.Add(time.Minute)
108 run(Job{})109 run(Job{})
109 run(Job{}) // repeated successful inspection has no finite pair change110 run(Job{}) // repeated successful inspection has no finite pair change
110- run(Job{ID: "fixture-active-source"})111+ run(Job{ID: "fixture-active-source", Type: RepositoryDocsIndexJobType, Status: JobStatusQueued,
112+ CacheUUID: m.entries[reg].CacheUUID, RepoID: m.entries[reg].RepoID, RegistrationID: reg,
113+ SourceRegistrationID: id, SourceRegistrationGeneration: m.sources[reg][id].State.SourceRegistrationGeneration})
111 run(Job{})114 run(Job{})
112 if m.sources[reg][id].State.State != "not_indexed" || m.sources[reg][id].State.Inspection.Result != "succeeded" {115 if m.sources[reg][id].State.State != "not_indexed" || m.sources[reg][id].State.Inspection.Result != "succeeded" {
113 t.Fatal("actual inspection/indexing owner changed authority")116 t.Fatal("actual inspection/indexing owner changed authority")
@@ -312,10 +315,14 @@ func TestMaintenanceSourceObservationStaleSnapshotAndSharedOverflow(t *testing.T
312 t.Fatal("stale completion changed current source authority")315 t.Fatal("stale completion changed current source authority")
313 }316 }
314 buffer := ctx.Value(maintenanceObservationKey{}).(*maintenanceObservationBuffer)317 buffer := ctx.Value(maintenanceObservationKey{}).(*maintenanceObservationBuffer)
315- // Parent's actual enrolled->indexing is the only transition above. The stale318+ if buffer.count != 0 || buffer.lost != 0 {
316- // source's ready projection was never the current authority owner.319+ t.Fatal("rejected stale source callback manufactured an event or loss")
320+ }
321+ // A qualified current finish supplies the real enrolled->indexing parent
322+ // transition; obsolete work can no longer mutate even the parent state.
323+ m.finishReconcileEntry(ctx, reg, cloneMaintenanceEntryPrivate(m.entries[reg]), 0, 0, "", "", nil, Job{}, Job{}, Job{}, time.Now().UTC())
317 if buffer.count != 1 || buffer.captures[0].scope != "registration" {324 if buffer.count != 1 || buffer.captures[0].scope != "registration" {
318- t.Fatal("stale source completion manufactured an event")325+ t.Fatal("current completion lost parent observation")
319 }326 }
320 entry, source := m.entries[reg], m.sources[reg][id]327 entry, source := m.entries[reg], m.sources[reg][id]
321 for i := 0; i < maxMaintenanceObservations+5; i++ {328 for i := 0; i < maxMaintenanceObservations+5; i++ {
@@ -366,17 +373,32 @@ func TestMaintenanceSourceObservationActualObsoleteJobAndCurrentControl(t *testi
366 }373 }
367 c := maintenanceEventCollector(t)374 c := maintenanceEventCollector(t)
368 m.jobs.SetObservation(c)375 m.jobs.SetObservation(c)
376+ before := cloneMaintenanceEntryPrivate(m.entries[reg])
377+ beforeSource := *m.sources[reg][id]
378+ writes := 0
379+ m.writeFile = func(name string, data []byte, permissions os.FileMode) error {
380+ writes++
381+ return durableAtomicWriteFile(name, data, permissions)
382+ }
369 ctx, flush := beginMaintenanceObservation(context.Background(), m.jobs)383 ctx, flush := beginMaintenanceObservation(context.Background(), m.jobs)
370 defer flush()384 defer flush()
371 updated := m.finishReconcileEntry(ctx, reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, active, time.Now().UTC())385 updated := m.finishReconcileEntry(ctx, reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, active, time.Now().UTC())
372- // Execution semantics predate instrumentation and remain unchanged.386+ if stale {
373- if updated.State != "indexing" || m.sources[reg][id].State.State != "indexing" || m.sources[reg][id].State.SourceRegistrationGeneration != 2 {387+ if !reflect.DeepEqual(updated, cloneMaintenanceEntry(&before)) {
374- t.Fatal("observation changed retained execution behavior")388+ t.Fatal("obsolete completion returned stale state")
389+ }
390+ assertObsoleteSourceCallbackUnchanged(t, m, reg, id, before, beforeSource, writes)
391+ } else if updated.State != "indexing" || m.sources[reg][id].State.State != "indexing" || m.sources[reg][id].State.SourceRegistrationGeneration != 2 || writes == 0 {
392+ t.Fatal("current source completion did not apply")
375 }393 }
376 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)394 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)
377 // Unchanged replay must not retroactively certify or recount the pair.395 // Unchanged replay must not retroactively certify or recount the pair.
378 m.finishReconcileEntry(ctx, reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, active, time.Now().UTC())396 m.finishReconcileEntry(ctx, reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, active, time.Now().UTC())
379 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)397 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)
398+ if stale {
399+ assertObsoleteSourceCallbackUnchanged(t, m, reg, id, before, beforeSource, writes)
400+ }
401+ assertSourceAuthorityRestart(t, m, reg, id, *m.sources[reg][id])
380 })402 })
381 }403 }
382}404}
@@ -420,19 +442,33 @@ func TestMaintenanceSourceObservationActualLateFailureAndCurrentControl(t *testi
420 t.Fatal("actual source rebind unavailable")442 t.Fatal("actual source rebind unavailable")
421 }443 }
422 }444 }
445+ before := cloneMaintenanceEntryPrivate(m.entries[reg])
446+ beforeSource := *m.sources[reg][id]
447+ var writes atomic.Int64
448+ m.writeFile = func(name string, data []byte, permissions os.FileMode) error {
449+ writes.Add(1)
450+ return durableAtomicWriteFile(name, data, permissions)
451+ }
423 unblock()452 unblock()
424 select {453 select {
425 case updated := <-done:454 case updated := <-done:
426- if updated.RepositoryDocs == nil || updated.RepositoryDocs.State != "degraded" {455+ if stale {
427- t.Fatal("observation changed existing failure return")456+ if !reflect.DeepEqual(updated, cloneMaintenanceEntry(&before)) {
457+ t.Fatal("obsolete failure returned stale state")
458+ }
459+ } else if updated.RepositoryDocs == nil || updated.RepositoryDocs.State != "degraded" {
460+ t.Fatal("current source failure did not apply")
428 }461 }
429 case <-time.After(5 * time.Second):462 case <-time.After(5 * time.Second):
430 t.Fatal("actual source scheduling failure did not unwind")463 t.Fatal("actual source scheduling failure did not unwind")
431 }464 }
432- if m.sources[reg][id].State.State != "degraded" || m.sources[reg][id].State.LastErrorClass != "repository_docs_provider_boundary_blocked" {465+ if stale {
466+ assertObsoleteSourceCallbackUnchanged(t, m, reg, id, before, beforeSource, int(writes.Load()))
467+ } else if m.sources[reg][id].State.State != "degraded" || m.sources[reg][id].State.LastErrorClass != "repository_docs_provider_boundary_blocked" || writes.Load() == 0 {
433 t.Fatal("actual local-only scheduling boundary unavailable")468 t.Fatal("actual local-only scheduling boundary unavailable")
434 }469 }
435 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)470 assertMaintenanceSourceAuthorityCaptures(t, ctx, stale)
471+ assertSourceAuthorityRestart(t, m, reg, id, *m.sources[reg][id])
436 })472 })
437 }473 }
438}474}
@@ -448,10 +484,54 @@ func assertMaintenanceSourceAuthorityCaptures(t *testing.T, ctx context.Context,
448 }484 }
449 expected, lost := 1, uint64(0)485 expected, lost := 1, uint64(0)
450 if stale {486 if stale {
451- expected, lost = 0, 1487+ expected = 0
452 }488 }
453 if sourceCaptures != expected || buffer.lost != lost {489 if sourceCaptures != expected || buffer.lost != lost {
454- t.Fatal("current confirmation and obsolete count-only loss not conserved")490+ t.Fatal("source confirmation or unchanged obsolete callback accounting incorrect")
491+ }
492+}
493+ 
494+func assertObsoleteSourceCallbackUnchanged(t *testing.T, m *MaintenanceManager, reg, id string, before MaintenanceEntry, beforeSource repositoryDocsRegisteredSource, writes int) {
495+ t.Helper()
496+ if writes != 0 || !reflect.DeepEqual(cloneMaintenanceEntryPrivate(m.entries[reg]), before) || *m.sources[reg][id] != beforeSource {
497+ t.Fatal("obsolete callback changed replacement authority or persisted state")
498+ }
499+ list, err := m.List(context.Background())
500+ if err != nil || len(list.Entries) != 1 || !reflect.DeepEqual(list.Entries[0], cloneMaintenanceEntry(&before)) {
501+ t.Fatal("public maintenance projection changed after obsolete callback")
502+ }
503+}
504+ 
505+func assertSourceAuthorityRestart(t *testing.T, m *MaintenanceManager, reg, id string, want repositoryDocsRegisteredSource) {
506+ t.Helper()
507+ reloaded := NewMaintenanceManager(m.manager, NewJobManager(""), m.path)
508+ if reloaded.Load() != nil {
509+ t.Fatal("durable source authority changed across restart")
510+ }
511+ // Existing registry canonicalization may redirect a rebound source's
512+ // original id to the id of its replacement Git/worktree/profile authority.
513+ // Require that exact redirect, not an arbitrary substitute source.
514+ canonicalID := reloaded.resolveSourceRegistrationIDLocked(id)
515+ if canonicalID != repositoryDocsSourceRegistrationID(reg, want.State.GitStoreRef, want.State.WorktreeRef, want.Profile) || reloaded.sources[reg][canonicalID] == nil {
516+ t.Fatal("durable source redirect does not match replacement authority")
517+ }
518+ want.State.SourceRegistrationID = canonicalID
519+ got := *reloaded.sources[reg][canonicalID]
520+ if got != want {
521+ changed := []string{}
522+ if got.RepositoryPath != want.RepositoryPath {
523+ changed = append(changed, "path")
524+ }
525+ if got.Profile != want.Profile {
526+ changed = append(changed, "profile")
527+ }
528+ a, b := reflect.ValueOf(got.State), reflect.ValueOf(want.State)
529+ for i := 0; i < a.NumField(); i++ {
530+ if !reflect.DeepEqual(a.Field(i).Interface(), b.Field(i).Interface()) {
531+ changed = append(changed, a.Type().Field(i).Name)
532+ }
533+ }
534+ t.Fatalf("durable source authority changed across restart: fields %s", strings.Join(changed, ","))
455 }535 }
456}536}
457 537 
@@ -0,0 +1,136 @@
1+package servicectl
2+ 
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "reflect"
8+ "testing"
9+ "time"
10+)
11+ 
12+func TestRepositoryDocsCallbacksRejectMismatchedAuthority(t *testing.T) {
13+ for _, owner := range []string{"finish", "failure"} {
14+ for _, mismatch := range []string{"registration_generation", "registration_id", "cache_uuid", "repo_id", "physical_cache", "source_generation", "source_path", "source_profile", "selected_source", "state_source", "other_source_target", "missing_source", "missing_registration"} {
15+ t.Run(owner+"/"+mismatch, func(t *testing.T) {
16+ m, _, repo, reg, id := inspectionBoundaryFixture(t)
17+ targetID := id
18+ if mismatch == "other_source_target" {
19+ otherRepo := createRepositoryDocsRegistrationRepo(t, repo+"-other", "fixture-other-source")
20+ other, ok, err := m.RegisterRepositoryDocsSource(context.Background(), m.entries[reg].CacheUUID, m.entries[reg].RepoID, otherRepo, "")
21+ if err != nil || !ok || other.RepositoryDocs == nil {
22+ t.Fatal("second source fixture unavailable")
23+ }
24+ targetID = other.RepositoryDocs.SourceRegistrationID
25+ }
26+ snapshot := cloneMaintenanceEntryPrivate(m.entries[reg])
27+ state := m.sources[reg][id].State
28+ snapshot.RepositoryDocs = &state
29+ snapshot.repositorySourceID = id
30+ snapshot.repositoryPath = m.sources[reg][id].RepositoryPath
31+ snapshot.repositoryProfile = m.sources[reg][id].Profile
32+ snapshot.RepositoryDocs.State = "ready"
33+ snapshot.RepositoryDocs.RevisionSetID = "fixture-stale-set"
34+ switch mismatch {
35+ case "registration_generation":
36+ snapshot.Generation--
37+ case "registration_id":
38+ snapshot.RegistrationID = "fixture-other-registration"
39+ case "cache_uuid":
40+ snapshot.CacheUUID = "fixture-other-cache"
41+ case "repo_id":
42+ snapshot.RepoID = "fixture/other-repo"
43+ case "physical_cache":
44+ snapshot.PathFingerprint = "fixture-other-physical-cache"
45+ case "source_generation":
46+ snapshot.RepositoryDocs.SourceRegistrationGeneration--
47+ case "source_path":
48+ snapshot.repositoryPath += "-obsolete"
49+ case "source_profile":
50+ snapshot.repositoryProfile = "fixture-other-profile"
51+ case "selected_source":
52+ snapshot.repositorySourceID = "fixture-other-source"
53+ case "state_source":
54+ snapshot.RepositoryDocs.SourceRegistrationID = "fixture-other-source"
55+ case "missing_source":
56+ delete(m.sources[reg], id)
57+ case "missing_registration":
58+ delete(m.entries, reg)
59+ }
60+ before := cloneMaintenanceEntryPrivate(m.entries[reg])
61+ beforeSources := cloneRepositoryDocsSources(m.sources[reg])
62+ beforeList, err := m.List(context.Background())
63+ if err != nil {
64+ t.Fatal("public source fixture unavailable")
65+ }
66+ writes := 0
67+ m.writeFile = func(string, []byte, os.FileMode) error {
68+ writes++
69+ return errors.New("fixture unexpected authority write")
70+ }
71+ m.jobs.SetObservation(maintenanceEventCollector(t))
72+ ctx, flush := beginMaintenanceObservation(context.Background(), m.jobs)
73+ defer flush()
74+ if owner == "finish" {
75+ // A different failure target is not part of finish's contract;
76+ // model the corresponding mismatched selected-source callback.
77+ if mismatch == "other_source_target" {
78+ snapshot.repositorySourceID = targetID
79+ }
80+ m.finishReconcileEntry(ctx, reg, snapshot, 10, 9, "ready", "fixture-namespace", nil, Job{}, Job{}, Job{}, time.Now().UTC())
81+ } else {
82+ m.updateRepositoryDocsFailure(ctx, reg, targetID, snapshot, "git_store_unavailable", "fixture failure")
83+ }
84+ afterList, err := m.List(context.Background())
85+ if err != nil || writes != 0 || !reflect.DeepEqual(beforeList, afterList) || !reflect.DeepEqual(before, cloneMaintenanceEntryPrivate(m.entries[reg])) || !reflect.DeepEqual(beforeSources, m.sources[reg]) {
86+ t.Fatal("mismatched callback changed retained source or scheduling authority")
87+ }
88+ buffer := ctx.Value(maintenanceObservationKey{}).(*maintenanceObservationBuffer)
89+ if buffer.count != 0 || buffer.lost != 0 {
90+ t.Fatal("unchanged rejected callback manufactured observation evidence or loss")
91+ }
92+ })
93+ }
94+ }
95+}
96+ 
97+func TestRepositoryDocsCompletionQualifiesActiveJobIntent(t *testing.T) {
98+ for _, mismatch := range []string{"current", "type", "status", "cache", "repository", "registration", "source", "generation"} {
99+ t.Run(mismatch, func(t *testing.T) {
100+ m, _, _, reg, id := inspectionBoundaryFixture(t)
101+ snapshot := cloneMaintenanceEntryPrivate(m.entries[reg])
102+ before := m.sources[reg][id].State
103+ job := Job{ID: "fixture-active-job", Type: RepositoryDocsIndexJobType, Status: JobStatusQueued,
104+ CacheUUID: snapshot.CacheUUID, RepoID: snapshot.RepoID, RegistrationID: reg,
105+ SourceRegistrationID: id, SourceRegistrationGeneration: before.SourceRegistrationGeneration}
106+ switch mismatch {
107+ case "type":
108+ job.Type = RAGIndexJobType
109+ case "status":
110+ job.Status = JobStatusSuperseded
111+ case "cache":
112+ job.CacheUUID = "fixture-other-cache"
113+ case "repository":
114+ job.RepoID = "fixture/other-repo"
115+ case "registration":
116+ job.RegistrationID = "fixture-other-registration"
117+ case "source":
118+ job.SourceRegistrationID = "fixture-other-source"
119+ case "generation":
120+ job.SourceRegistrationGeneration--
121+ }
122+ // A registered but not-yet-indexed source normally makes the parent
123+ // registration indexing even without an active job. Compare that
124+ // existing derivation, not a fabricated ready parent status.
125+ baseline := m.finishReconcileEntry(context.Background(), reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, Job{}, time.Now().UTC())
126+ got := m.finishReconcileEntry(context.Background(), reg, snapshot, 0, 0, "", "", nil, Job{}, Job{}, job, time.Now().UTC())
127+ if mismatch == "current" {
128+ if m.sources[reg][id].State.State != "indexing" || got.State != "indexing" || len(got.ActiveJobs) != 1 || got.ActiveJobs[0] != job.ID {
129+ t.Fatal("current active source job did not apply")
130+ }
131+ } else if m.sources[reg][id].State != before || len(got.ActiveJobs) != 0 || got.State != baseline.State {
132+ t.Fatal("unrelated or terminal job copy acquired current source authority")
133+ }
134+ })
135+ }
136+}
@@ -56,16 +56,29 @@ func (m *MaintenanceManager) retainRepositoryDocsInspection(snapshot *Maintenanc
56}56}
57 57 
58func (m *MaintenanceManager) inspectionAuthorityLocked(snapshot *MaintenanceEntry) (*MaintenanceEntry, *repositoryDocsRegisteredSource, bool) {58func (m *MaintenanceManager) inspectionAuthorityLocked(snapshot *MaintenanceEntry) (*MaintenanceEntry, *repositoryDocsRegisteredSource, bool) {
59- entry := m.entries[snapshot.RegistrationID]59+ entry, currentEntry := m.reconcileAuthorityLocked(snapshot)
60 state := snapshot.RepositoryDocs60 state := snapshot.RepositoryDocs
61- if entry == nil || state == nil {61+ if !currentEntry || state == nil {
62 return entry, nil, false62 return entry, nil, false
63 }63 }
64 source := m.sources[snapshot.RegistrationID][state.SourceRegistrationID]64 source := m.sources[snapshot.RegistrationID][state.SourceRegistrationID]
65- current := source != nil && entry.Generation == snapshot.Generation && entry.CacheUUID == snapshot.CacheUUID && entry.RepoID == snapshot.RepoID && entry.PathFingerprint == snapshot.PathFingerprint && source.RepositoryPath == snapshot.repositoryPath && source.State.SourceRegistrationGeneration == state.SourceRegistrationGeneration65+ current := source != nil && state.SourceRegistrationID == snapshot.repositorySourceID &&
66+ source.State.SourceRegistrationID == state.SourceRegistrationID &&
67+ source.RepositoryPath == snapshot.repositoryPath && source.Profile == snapshot.repositoryProfile &&
68+ source.State.SourceRegistrationGeneration == state.SourceRegistrationGeneration
66 return entry, source, current69 return entry, source, current
67}70}
68 71 
72+// Completion authority belongs to the snapshot that started the work, not a
73+// replacement registration found later under the same map key. Call under mu
74+// before mutating either scheduling state or source state.
75+func (m *MaintenanceManager) reconcileAuthorityLocked(snapshot *MaintenanceEntry) (*MaintenanceEntry, bool) {
76+ entry := m.entries[snapshot.RegistrationID]
77+ return entry, entry != nil && entry.RegistrationID == snapshot.RegistrationID &&
78+ entry.Generation == snapshot.Generation && entry.CacheUUID == snapshot.CacheUUID &&
79+ entry.RepoID == snapshot.RepoID && entry.PathFingerprint == snapshot.PathFingerprint
80+}
81+ 
69func inspectionHealthReason(class string) string {82func inspectionHealthReason(class string) string {
70 switch class {83 switch class {
71 case "git_store":84 case "git_store":