mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(desktop): preserve recovery identity and retire catalog reconciliation
Problem: legacy recovery rows change topic identity after content proof or parent deletion. Catalog rebuild can leave an explicit reconcile waiting on paused discovery, and the Windows canonical-open test races a five-second filesystem-poll deadline. Root cause: metadata and content projections choose different fallback IDs; reconcile jobs borrow the application lifetime and can attach to another catalog generation; readiness assertions assume a particular disk speed. Fix: anchor legacy groups to the existing legacy identity while preserving explicit topics and authoritative files. Bind reconcile jobs to the captured catalog and cancellation owner, detach them atomically on stop, release them on invalidation, and fence old-job cleanup by pointer identity. Join the existing history preparation before asserting readable history under a gated runtime, and fence metadata/content-proof phases in recovery tests. Verification: full session-catalog race suite, deterministic pre-fix failures and focused Desktop race regressions, native Windows C group and repeated canonical/recovery/lifecycle tests, pinned root/Desktop/Windows lint and repolint. Complete Desktop inventory and historical version/scope coverage are recorded in the PR validation.
This commit is contained in:
1 parent
e045489e46
commit
fe060ae88f
9 files changed
+638
-128
No files matched your search
+49
-37
@@ -252,24 +252,26 @@ func (a *App) stopSessionCatalog(timeout time.Duration) bool {
|
||||
a.catalogInitialReconcileDone = nil
|
||||
a.catalogMetadataRequests = nil
|
||||
a.catalogClosing = closing
|
||||
a.catalogLifecycleMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
catalog := a.sessionCatalog.Swap(nil)
|
||||
// Pair the nil publication with the request-side locked recheck. Once this
|
||||
// barrier passes, the snapshot contains every reconcile that can use catalog
|
||||
// and no new one can be added.
|
||||
// Pair nil publication with the request-side locked recheck. Keep the
|
||||
// lifecycle lock through detachment so replacement publication cannot
|
||||
// enter this retired-job snapshot.
|
||||
a.catalogReconcileMu.Lock()
|
||||
reconcileDone := make([]<-chan struct{}, 0, len(a.catalogReconcileJobs))
|
||||
for _, job := range a.catalogReconcileJobs {
|
||||
job.cancel()
|
||||
reconcileDone = append(reconcileDone, job.done)
|
||||
}
|
||||
clear(a.catalogReconcileJobs)
|
||||
a.catalogReconcileMu.Unlock()
|
||||
a.catalogLifecycleMu.Unlock()
|
||||
go func() {
|
||||
defer close(closing.done)
|
||||
// A reconcile may wait on discovery that stopping has paused, so it
|
||||
// only defers the close until the deadline, never past it.
|
||||
// Cancellation releases queued discovery waits. Bound any synchronous
|
||||
// admission still in flight by the original close deadline.
|
||||
for _, reconciled := range reconcileDone {
|
||||
if !waitChannelBefore(reconciled, deadline) {
|
||||
break
|
||||
@@ -417,9 +419,12 @@ func (a *App) emitProjectTreeChangedV2(revision uint64, roots []string, reason s
|
||||
}
|
||||
|
||||
type desktopCatalogReconcileJob struct {
|
||||
target sessioncatalog.DirectoryTarget
|
||||
dirty bool
|
||||
done chan struct{}
|
||||
target sessioncatalog.DirectoryTarget
|
||||
dirty bool
|
||||
catalog *sessioncatalog.Catalog
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (a *App) requestSessionCatalogReconcile(dir string) bool {
|
||||
@@ -444,62 +449,69 @@ func (a *App) requestSessionCatalogReconcile(dir string) bool {
|
||||
if a.catalogReconcileJobs == nil {
|
||||
a.catalogReconcileJobs = map[string]*desktopCatalogReconcileJob{}
|
||||
}
|
||||
if job := a.catalogReconcileJobs[key]; job != nil {
|
||||
if job := a.catalogReconcileJobs[key]; job != nil && job.catalog == catalog && job.ctx.Err() == nil {
|
||||
job.target = target
|
||||
job.dirty = true
|
||||
a.catalogReconcileMu.Unlock()
|
||||
return true
|
||||
}
|
||||
done := make(chan struct{})
|
||||
a.catalogReconcileJobs[key] = &desktopCatalogReconcileJob{target: target, done: done}
|
||||
ctx, cancel := context.WithCancel(a.bootContext())
|
||||
job := &desktopCatalogReconcileJob{target: target, catalog: catalog, ctx: ctx, cancel: cancel, done: make(chan struct{})}
|
||||
a.catalogReconcileJobs[key] = job
|
||||
a.catalogReconcileMu.Unlock()
|
||||
go a.runSessionCatalogReconcile(key, done)
|
||||
go a.runSessionCatalogReconcile(key, job)
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) runSessionCatalogReconcile(key string, done chan struct{}) {
|
||||
defer close(done)
|
||||
func (a *App) runSessionCatalogReconcile(key string, job *desktopCatalogReconcileJob) {
|
||||
defer func() {
|
||||
job.cancel()
|
||||
a.catalogReconcileMu.Lock()
|
||||
if a.catalogReconcileJobs[key] == job {
|
||||
delete(a.catalogReconcileJobs, key)
|
||||
}
|
||||
a.catalogReconcileMu.Unlock()
|
||||
close(job.done)
|
||||
}()
|
||||
for {
|
||||
a.catalogReconcileMu.Lock()
|
||||
job := a.catalogReconcileJobs[key]
|
||||
if job == nil {
|
||||
if a.catalogReconcileJobs[key] != job || job.ctx.Err() != nil || a.shuttingDown.Load() {
|
||||
a.catalogReconcileMu.Unlock()
|
||||
return
|
||||
}
|
||||
target := job.target
|
||||
job.dirty = false
|
||||
a.catalogReconcileMu.Unlock()
|
||||
catalog := a.sessionCatalog.Load()
|
||||
if catalog == nil || a.shuttingDown.Load() {
|
||||
a.catalogReconcileMu.Lock()
|
||||
delete(a.catalogReconcileJobs, key)
|
||||
a.catalogReconcileMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if a.catalogReconcileHook != nil {
|
||||
a.catalogReconcileHook(target)
|
||||
}
|
||||
// Discovery projects metadata without rewriting organization sidecars or
|
||||
// proving recovery ancestry through transcript reads.
|
||||
if settled, accepted := catalog.ScheduleReconcile(target); accepted {
|
||||
// Discovery projects metadata only. Retired or invalidated owners cannot
|
||||
// settle paused tokens, so bind scheduling and completion to this job.
|
||||
if job.ctx.Err() == nil && a.sessionCatalog.Load() == job.catalog {
|
||||
select {
|
||||
case <-settled:
|
||||
case <-a.bootContext().Done():
|
||||
case <-job.catalog.Invalidated():
|
||||
job.cancel()
|
||||
default:
|
||||
if settled, accepted := job.catalog.ScheduleReconcile(target); accepted {
|
||||
select {
|
||||
case <-settled:
|
||||
case <-job.ctx.Done():
|
||||
case <-job.catalog.Invalidated():
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.catalogReconcileMu.Lock()
|
||||
job = a.catalogReconcileJobs[key]
|
||||
if job == nil {
|
||||
a.catalogReconcileMu.Unlock()
|
||||
return
|
||||
}
|
||||
if job.dirty && !a.shuttingDown.Load() {
|
||||
if a.catalogReconcileJobs[key] == job && job.dirty && job.ctx.Err() == nil &&
|
||||
a.sessionCatalog.Load() == job.catalog && !a.shuttingDown.Load() {
|
||||
a.catalogReconcileMu.Unlock()
|
||||
continue
|
||||
}
|
||||
delete(a.catalogReconcileJobs, key)
|
||||
if a.catalogReconcileJobs[key] == job {
|
||||
delete(a.catalogReconcileJobs, key)
|
||||
}
|
||||
a.catalogReconcileMu.Unlock()
|
||||
if a.catalogReconcileDoneHook != nil {
|
||||
a.catalogReconcileDoneHook(target)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
func TestStopSessionCatalogJoinsReconcileQueuedBeforeDiscoveryStarts(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
app := NewApp()
|
||||
app.ctx = ctx
|
||||
requested := make(chan struct{}, 1)
|
||||
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
|
||||
InMemory: true, MetadataOnly: true, StartPaused: true,
|
||||
OnDiscovery: func(event sessioncatalog.DiscoveryEvent) {
|
||||
if event.Phase == "requested" {
|
||||
requested <- struct{}{}
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
var jobDone <-chan struct{}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
if !app.stopSessionCatalog(sessionCatalogTestDeadline) {
|
||||
t.Error("catalog fixture did not close cleanly")
|
||||
}
|
||||
if jobDone != nil && !waitChannelBefore(jobDone, time.Now().Add(sessionCatalogTestDeadline)) {
|
||||
t.Error("explicit reconcile outlived its fixture")
|
||||
}
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
if !app.requestSessionCatalogReconcile(dir) {
|
||||
t.Fatal("explicit reconcile was not admitted")
|
||||
}
|
||||
app.catalogReconcileMu.Lock()
|
||||
jobDone = app.catalogReconcileJobs[projectRootKey(dir)].done
|
||||
app.catalogReconcileMu.Unlock()
|
||||
// The worker is inside ScheduleReconcile, but paused discovery cannot settle
|
||||
// its token. Stop must cancel and join the desktop job independently.
|
||||
<-requested
|
||||
if !app.stopSessionCatalog(time.Second) {
|
||||
t.Fatal("catalog stop did not join its paused discovery waiter")
|
||||
}
|
||||
select {
|
||||
case <-jobDone:
|
||||
default:
|
||||
t.Fatal("catalog stop returned before its reconcile goroutine exited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredSessionCatalogReconcileCannotJoinReplacement(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
app := NewApp()
|
||||
app.ctx = ctx
|
||||
var oldRequests atomic.Int32
|
||||
openPaused := func(onDiscovery func(sessioncatalog.DiscoveryEvent)) *sessioncatalog.Catalog {
|
||||
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
|
||||
InMemory: true, MetadataOnly: true, StartPaused: true, OnDiscovery: onDiscovery,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
oldCatalog := openPaused(func(event sessioncatalog.DiscoveryEvent) {
|
||||
if event.Phase == "requested" {
|
||||
oldRequests.Add(1)
|
||||
}
|
||||
})
|
||||
app.sessionCatalog.Store(oldCatalog)
|
||||
entered := make(chan struct{}, 2)
|
||||
oldRelease, newRelease := make(chan struct{}), make(chan struct{})
|
||||
var oldOnce, newOnce sync.Once
|
||||
var calls atomic.Int32
|
||||
app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) {
|
||||
call := calls.Add(1)
|
||||
entered <- struct{}{}
|
||||
if call == 1 {
|
||||
<-oldRelease
|
||||
} else {
|
||||
<-newRelease
|
||||
}
|
||||
}
|
||||
var jobs []<-chan struct{}
|
||||
t.Cleanup(func() {
|
||||
oldOnce.Do(func() { close(oldRelease) })
|
||||
newOnce.Do(func() { close(newRelease) })
|
||||
cancel()
|
||||
if !app.stopSessionCatalog(sessionCatalogTestDeadline) {
|
||||
t.Error("replacement catalog fixture did not close cleanly")
|
||||
}
|
||||
for _, done := range jobs {
|
||||
if !waitChannelBefore(done, time.Now().Add(sessionCatalogTestDeadline)) {
|
||||
t.Error("retired reconcile outlived its fixture")
|
||||
}
|
||||
}
|
||||
})
|
||||
dir := t.TempDir()
|
||||
if !app.requestSessionCatalogReconcile(dir) {
|
||||
t.Fatal("old reconcile was not admitted")
|
||||
}
|
||||
key := projectRootKey(dir)
|
||||
app.catalogReconcileMu.Lock()
|
||||
oldJob := app.catalogReconcileJobs[key]
|
||||
app.catalogReconcileMu.Unlock()
|
||||
jobs = append(jobs, oldJob.done)
|
||||
<-entered
|
||||
if app.stopSessionCatalog(25 * time.Millisecond) {
|
||||
t.Fatal("stop joined the old job before its hook released")
|
||||
}
|
||||
|
||||
// A timed-out rebuild may publish the next catalog while the old hook is
|
||||
// still unwinding. The same directory must receive a distinct job owner.
|
||||
app.sessionCatalog.Store(openPaused(nil))
|
||||
if !app.requestSessionCatalogReconcile(dir) {
|
||||
t.Fatal("replacement reconcile was not admitted")
|
||||
}
|
||||
app.catalogReconcileMu.Lock()
|
||||
newJob := app.catalogReconcileJobs[key]
|
||||
app.catalogReconcileMu.Unlock()
|
||||
jobs = append(jobs, newJob.done)
|
||||
if oldJob == newJob {
|
||||
t.Fatal("replacement catalog inherited a retired reconcile job")
|
||||
}
|
||||
<-entered
|
||||
oldOnce.Do(func() { close(oldRelease) })
|
||||
if !waitChannelBefore(oldJob.done, time.Now().Add(time.Second)) {
|
||||
t.Fatal("retired reconcile did not exit after its hook released")
|
||||
}
|
||||
app.catalogReconcileMu.Lock()
|
||||
currentJob := app.catalogReconcileJobs[key]
|
||||
app.catalogReconcileMu.Unlock()
|
||||
if currentJob != newJob {
|
||||
t.Fatal("retired reconcile removed the replacement job")
|
||||
}
|
||||
if oldRequests.Load() != 0 {
|
||||
t.Fatal("retired reconcile scheduled discovery after cancellation")
|
||||
}
|
||||
newOnce.Do(func() { close(newRelease) })
|
||||
if !app.stopSessionCatalog(time.Second) {
|
||||
t.Fatal("replacement reconcile did not stop cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidatedSessionCatalogReleasesQueuedReconcileWithoutAppShutdown(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
app := NewApp()
|
||||
app.ctx = ctx
|
||||
requested := make(chan struct{}, 1)
|
||||
path := filepath.Join(t.TempDir(), "catalog.sqlite")
|
||||
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
|
||||
Path: path, MetadataOnly: true, StartPaused: true,
|
||||
OnDiscovery: func(event sessioncatalog.DiscoveryEvent) {
|
||||
if event.Phase == "requested" {
|
||||
requested <- struct{}{}
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
var jobDone <-chan struct{}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
if !app.stopSessionCatalog(sessionCatalogTestDeadline) {
|
||||
t.Error("invalidated catalog fixture did not close cleanly")
|
||||
}
|
||||
if jobDone != nil && !waitChannelBefore(jobDone, time.Now().Add(sessionCatalogTestDeadline)) {
|
||||
t.Error("invalidated reconcile outlived its fixture")
|
||||
}
|
||||
})
|
||||
dir := t.TempDir()
|
||||
if !app.requestSessionCatalogReconcile(dir) {
|
||||
t.Fatal("explicit reconcile was not admitted")
|
||||
}
|
||||
app.catalogReconcileMu.Lock()
|
||||
jobDone = app.catalogReconcileJobs[projectRootKey(dir)].done
|
||||
app.catalogReconcileMu.Unlock()
|
||||
<-requested
|
||||
|
||||
// Corrupt this disposable generation's root page. A fresh public read lease
|
||||
// must report corruption and revoke it while discovery is still paused.
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`PRAGMA writable_schema=ON; UPDATE sqlite_master SET rootpage=2147483647 WHERE type='table' AND name='catalog_state'; PRAGMA schema_version=999`)
|
||||
closeErr := db.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
t.Fatal(err, closeErr)
|
||||
}
|
||||
lease, err := catalog.OpenReadLease(ctx)
|
||||
if lease != nil {
|
||||
lease.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("corrupt root page remained readable")
|
||||
}
|
||||
select {
|
||||
case <-catalog.Invalidated():
|
||||
default:
|
||||
t.Fatalf("public read did not invalidate the corrupt generation: %v", err)
|
||||
}
|
||||
if !waitChannelBefore(jobDone, time.Now().Add(time.Second)) {
|
||||
t.Fatal("invalidated discovery kept the desktop job waiting for app shutdown")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("reconcile completion depended on app shutdown")
|
||||
}
|
||||
}
|
||||
@@ -734,28 +734,6 @@ func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmodifiedRecoveryCopyDoesNotMigrateIntoTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal")
|
||||
coverDesktopRecoveryParent(t, parent, branchMsgs)
|
||||
|
||||
app := NewApp()
|
||||
nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent))
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
|
||||
t.Fatalf("project tree = %#v, want both sources before content proof", nodes)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
||||
t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
|
||||
} else if strings.TrimSpace(meta.TopicID) != "" {
|
||||
t.Fatalf("parent-covered recovery copy was migrated into topic %q", meta.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoveredRecoveryCopyBecomesVisibleAfterMigratedParentDeletion(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
@@ -15,23 +15,30 @@ import (
|
||||
|
||||
func canonicalTopicHistoryReady(t *testing.T, app *App, tabID string) session.HistoryWindowPage {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
page, err := app.SessionHistoryWindowForTab(tabID, session.HistoryWindowRequest{Anchor: "newest", Limit: 32})
|
||||
req := session.HistoryWindowRequest{Anchor: "newest", Limit: 32}
|
||||
page, err := app.SessionHistoryWindowForTab(tabID, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Status == "preparing" {
|
||||
query, ref, err := app.canonicalSessionQuery(tabID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Status != "preparing" {
|
||||
if page.Status != "ready" || len(page.Messages) == 0 {
|
||||
t.Fatalf("history is not readable: status=%s messages=%d", page.Status, len(page.Messages))
|
||||
}
|
||||
return page
|
||||
// TitleMessages joins this query's locator preparation, so completion
|
||||
// supplies the readiness boundary without polling filesystem speed.
|
||||
if _, err := query.TitleMessages(t.Context(), ref, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("independent history preparation did not finish")
|
||||
page, err = app.SessionHistoryWindowForTab(tabID, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if page.Status != "ready" || len(page.Messages) == 0 {
|
||||
t.Fatalf("history is not readable: status=%s messages=%d", page.Status, len(page.Messages))
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func TestCanonicalSidebarActivationReplacesRemoteSurface(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
func TestCanonicalInactiveHistoryPreparationWaitsForItsOwnerWithoutRuntime(t *testing.T) {
|
||||
app, source, target, root, _ := canonicalWorkspaceOpenFixture(t)
|
||||
app.readyHook = func() {}
|
||||
installNoopRuntimeEvents(app, source.sink)
|
||||
gate := newTabBuildGate(app)
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
t.Cleanup(gate.releaseAll)
|
||||
release, err := app.historyMaintenance.Foreground(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(release)
|
||||
if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), target.Ref().SessionID, "owner-gated-topic", "History owner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := app.openProjectTabInactive(root, "owner-gated-topic")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.waitEntered(t, meta.ID)
|
||||
request := session.HistoryWindowRequest{Anchor: "newest", Limit: 32}
|
||||
page, err := app.SessionHistoryWindowForTab(meta.ID, request)
|
||||
if err != nil || page.Status != "preparing" {
|
||||
t.Fatalf("history before admission: %+v, %v", page, err)
|
||||
}
|
||||
query, ref, err := app.canonicalSessionQuery(meta.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entered := make(chan struct{})
|
||||
wait := &topicHistoryOwnerWaitContext{Context: t.Context(), entered: entered}
|
||||
done := make(chan struct{})
|
||||
var prepareErr error
|
||||
go func() {
|
||||
_, prepareErr = query.TitleMessages(wait, ref, 1)
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() { release(); <-done })
|
||||
<-entered
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("history barrier completed while admission was held: %v", prepareErr)
|
||||
default:
|
||||
}
|
||||
release()
|
||||
<-done
|
||||
if prepareErr != nil {
|
||||
t.Fatal(prepareErr)
|
||||
}
|
||||
page, err = app.SessionHistoryWindowForTab(meta.ID, request)
|
||||
if err != nil || page.Status != "ready" || len(page.Messages) == 0 || page.Messages[len(page.Messages)-1].MessageID != "session-B-user" {
|
||||
t.Fatalf("history after preparation: %+v, %v", page, err)
|
||||
}
|
||||
app.mu.RLock()
|
||||
defer app.mu.RUnlock()
|
||||
if app.activeTabID != source.ID || app.tabs[meta.ID].Ctrl != nil || app.tabs[meta.ID].Ready {
|
||||
t.Fatal("history preparation started the gated runtime or changed the visible session")
|
||||
}
|
||||
}
|
||||
|
||||
type topicHistoryOwnerWaitContext struct {
|
||||
context.Context
|
||||
entered chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *topicHistoryOwnerWaitContext) Done() <-chan struct{} {
|
||||
c.once.Do(func() { close(c.entered) })
|
||||
return c.Context.Done()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestUnmodifiedRecoveryCopyDoesNotMigrateIntoTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal")
|
||||
coverDesktopRecoveryParent(t, parent, branchMsgs)
|
||||
metaPath := agent.BranchMetaPath(recovery)
|
||||
before, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
// Metadata discovery may proceed, but content proof must wait until this
|
||||
// test has observed both sources. Wall-clock timing cannot fence that phase.
|
||||
release, err := app.historyMaintenance.Foreground(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
app.startSessionCatalog()
|
||||
catalog := waitForSessionCatalogForTest(t, app, nil)
|
||||
catalog.PrioritizeWorkspace("global", "")
|
||||
nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent))
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
|
||||
t.Fatalf("project tree = %#v, want both sources before content proof", nodes)
|
||||
}
|
||||
release()
|
||||
if err := catalog.ProveRecoveryLineage(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodes = mustListProjectTree(t, app)
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 1 || filepath.Clean(nodes[0].Children[0].SessionPath) != filepath.Clean(parent) {
|
||||
t.Fatalf("project tree = %#v, want the covering parent after content proof", nodes)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
||||
t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
|
||||
} else if strings.TrimSpace(meta.TopicID) != "" {
|
||||
t.Fatalf("parent-covered recovery copy was migrated into topic %q", meta.TopicID)
|
||||
}
|
||||
after, err := os.ReadFile(metaPath)
|
||||
if err != nil || string(after) != string(before) {
|
||||
t.Fatalf("content proof changed the recovery sidecar: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package sessioncatalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func TestLegacyRecoveryIdentitySurvivesContentProofAndParentDeletion(t *testing.T) {
|
||||
for _, metadataOnly := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "full", true: "metadata"}[metadataOnly], func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
dir := t.TempDir()
|
||||
parent, recovery, _ := forkCatalogRecoveryBranch(t, dir, "legacy-parent")
|
||||
coverCatalogRecoveryParent(t, parent, recovery)
|
||||
before := map[string]string{}
|
||||
for _, path := range []string{parent, recovery, agent.BranchMetaPath(parent), agent.BranchMetaPath(recovery)} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[path] = string(data)
|
||||
}
|
||||
opts := Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: metadataOnly, DisableRepair: true, StartPaused: true}
|
||||
catalog, err := Open(ctx, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := catalog.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
reopen := func() {
|
||||
t.Helper()
|
||||
if err := catalog.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err = Open(ctx, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
target := DirectoryTarget{Path: dir, Scope: "global"}
|
||||
check := func(path, topic string, visible bool) {
|
||||
t.Helper()
|
||||
record, ok, err := catalog.GetSession(ctx, path)
|
||||
if err != nil || !ok || record.TopicID != topic || record.OrdinaryVisible != visible {
|
||||
t.Fatalf("record = %+v, ok=%v err=%v; want topic=%q visible=%v", record, ok, err, topic, visible)
|
||||
}
|
||||
}
|
||||
reconcile := func() {
|
||||
t.Helper()
|
||||
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.ProveRecoveryLineage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
reconcile()
|
||||
check(parent, agent.LegacySessionTopicID(parent), true)
|
||||
check(recovery, agent.LegacySessionTopicID(parent), false)
|
||||
// An older executable may have certified this unchanged directory
|
||||
// with the incompatible fallback. Reopen must repair that cache too.
|
||||
oldTopic := "recovery:" + agent.BranchID(parent)
|
||||
for _, query := range []string{
|
||||
"UPDATE catalog_sessions SET topic_id=?,logical_topic_id=?",
|
||||
"UPDATE catalog_topics SET topic_id=? WHERE topic_id<>?",
|
||||
} {
|
||||
if _, err := catalog.db.ExecContext(ctx, query, oldTopic, oldTopic); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
reopen()
|
||||
reconcile()
|
||||
check(parent, agent.LegacySessionTopicID(parent), true)
|
||||
check(recovery, agent.LegacySessionTopicID(parent), false)
|
||||
if err := os.Remove(parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(agent.SessionEventLogPath(parent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconcile()
|
||||
check(recovery, agent.LegacySessionTopicID(recovery), true)
|
||||
reopen()
|
||||
reconcile()
|
||||
check(recovery, agent.LegacySessionTopicID(recovery), true)
|
||||
// Reopening a rebuilt projection must converge without migrating the
|
||||
// authoritative transcript or either legacy branch sidecar.
|
||||
for _, path := range []string{recovery, agent.BranchMetaPath(parent), agent.BranchMetaPath(recovery)} {
|
||||
data, err := os.ReadFile(filepath.Clean(path))
|
||||
if err != nil || string(data) != before[path] {
|
||||
t.Fatalf("catalog changed authority %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRootlessRecoveryGroupUsesOneRepresentativeIdentity(t *testing.T) {
|
||||
leaf := SessionRecord{Path: filepath.Join(t.TempDir(), "leaf.jsonl"), Recovered: true, ParentID: "gone", RecoveryGroupID: "gone", RecoveryCanonical: true}
|
||||
peer := SessionRecord{Path: filepath.Join(filepath.Dir(leaf.Path), "peer.jsonl"), Recovered: true, ParentID: "gone", RecoveryGroupID: "gone"}
|
||||
for _, input := range [][]SessionRecord{{leaf, peer}, {peer, leaf}} {
|
||||
visible := 0
|
||||
for _, got := range projectLogicalSessions(input) {
|
||||
if got.TopicID != agent.LegacySessionTopicID(leaf.Path) || got.LogicalTopicID != got.TopicID {
|
||||
t.Fatalf("rootless group identity = %+v", got)
|
||||
}
|
||||
if got.OrdinaryVisible {
|
||||
visible++
|
||||
if got.Path != leaf.Path {
|
||||
t.Fatalf("visible recovery = %q, want canonical leaf", got.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if visible != 1 {
|
||||
t.Fatalf("visible recovery rows = %d, want 1", visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,64 +274,7 @@ func projectLogicalSessions(records []SessionRecord) []SessionRecord {
|
||||
}
|
||||
}
|
||||
|
||||
type groupAnchor struct {
|
||||
topicID string
|
||||
topicTitle string
|
||||
createdAt int64
|
||||
rootIndex int
|
||||
}
|
||||
anchors := map[string]*groupAnchor{}
|
||||
for i, rec := range records {
|
||||
if !rec.Recovered {
|
||||
id := agent.BranchID(rec.Path)
|
||||
anchors[id] = &groupAnchor{
|
||||
topicID: rec.TopicID, topicTitle: rec.TopicTitle,
|
||||
createdAt: rec.CreatedAt, rootIndex: i,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill missing logical topics from recovered members (root topic empty or
|
||||
// root missing). Prefer earliest non-empty member topic_id.
|
||||
for _, rec := range records {
|
||||
if !rec.Recovered {
|
||||
continue
|
||||
}
|
||||
groupID := strings.TrimSpace(rec.RecoveryGroupID)
|
||||
if groupID == "" {
|
||||
continue
|
||||
}
|
||||
anchor := anchors[groupID]
|
||||
if anchor == nil {
|
||||
anchor = &groupAnchor{rootIndex: -1, createdAt: rec.CreatedAt}
|
||||
anchors[groupID] = anchor
|
||||
}
|
||||
topic := strings.TrimSpace(rec.TopicID)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(anchor.topicID) == "" ||
|
||||
(rec.CreatedAt > 0 && (anchor.createdAt == 0 || rec.CreatedAt < anchor.createdAt) &&
|
||||
(anchor.rootIndex < 0 || strings.TrimSpace(records[anchor.rootIndex].TopicID) == "")) {
|
||||
// Keep a non-empty root topic when present; only override empty roots.
|
||||
if anchor.rootIndex >= 0 && strings.TrimSpace(records[anchor.rootIndex].TopicID) != "" {
|
||||
continue
|
||||
}
|
||||
anchor.topicID = topic
|
||||
anchor.topicTitle = rec.TopicTitle
|
||||
if rec.CreatedAt > 0 {
|
||||
anchor.createdAt = rec.CreatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
for groupID, anchor := range anchors {
|
||||
if strings.TrimSpace(anchor.topicID) == "" {
|
||||
// Stable internal topic so rootless recovery storms still collapse.
|
||||
anchor.topicID = "recovery:" + groupID
|
||||
if anchor.topicTitle == "" {
|
||||
anchor.topicTitle = groupID
|
||||
}
|
||||
}
|
||||
}
|
||||
anchors := logicalSessionAnchors(records)
|
||||
for i := range records {
|
||||
groupID := ""
|
||||
if records[i].Recovered {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package sessioncatalog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
type groupAnchor struct {
|
||||
topicID string
|
||||
topicTitle string
|
||||
createdAt int64
|
||||
rootIndex int
|
||||
fallbackIndex int
|
||||
}
|
||||
|
||||
func logicalSessionAnchors(records []SessionRecord) map[string]*groupAnchor {
|
||||
anchors := map[string]*groupAnchor{}
|
||||
for i, rec := range records {
|
||||
if !rec.Recovered {
|
||||
id := agent.BranchID(rec.Path)
|
||||
anchors[id] = &groupAnchor{
|
||||
topicID: rec.TopicID, topicTitle: rec.TopicTitle,
|
||||
createdAt: rec.CreatedAt, rootIndex: i, fallbackIndex: i,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill missing logical topics from recovered members (root topic empty or
|
||||
// root missing). Prefer earliest non-empty member topic_id.
|
||||
for i, rec := range records {
|
||||
if !rec.Recovered {
|
||||
continue
|
||||
}
|
||||
groupID := strings.TrimSpace(rec.RecoveryGroupID)
|
||||
if groupID == "" {
|
||||
continue
|
||||
}
|
||||
anchor := anchors[groupID]
|
||||
if anchor == nil {
|
||||
anchor = &groupAnchor{rootIndex: -1, createdAt: rec.CreatedAt, fallbackIndex: i}
|
||||
anchors[groupID] = anchor
|
||||
}
|
||||
if anchor.rootIndex < 0 {
|
||||
preferred := pickPreferredRecovery([]SessionRecord{records[anchor.fallbackIndex], rec})
|
||||
if preferred.Path == rec.Path {
|
||||
anchor.fallbackIndex = i
|
||||
}
|
||||
}
|
||||
topic := strings.TrimSpace(rec.TopicID)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(anchor.topicID) == "" ||
|
||||
(rec.CreatedAt > 0 && (anchor.createdAt == 0 || rec.CreatedAt < anchor.createdAt) &&
|
||||
(anchor.rootIndex < 0 || strings.TrimSpace(records[anchor.rootIndex].TopicID) == "")) {
|
||||
// Keep a non-empty root topic when present; only override empty roots.
|
||||
if anchor.rootIndex >= 0 && strings.TrimSpace(records[anchor.rootIndex].TopicID) != "" {
|
||||
continue
|
||||
}
|
||||
anchor.topicID = topic
|
||||
anchor.topicTitle = rec.TopicTitle
|
||||
if rec.CreatedAt > 0 {
|
||||
anchor.createdAt = rec.CreatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, anchor := range anchors {
|
||||
if strings.TrimSpace(anchor.topicID) == "" {
|
||||
// Content proof must retain metadata discovery's legacy identity.
|
||||
// With no surviving root, use the ordinary recovery representative;
|
||||
// every sibling still folds onto that one topic without sidecar writes.
|
||||
path := records[anchor.fallbackIndex].Path
|
||||
anchor.topicID = agent.LegacySessionTopicID(path)
|
||||
if anchor.topicTitle == "" {
|
||||
anchor.topicTitle = strings.TrimSuffix(filepath.Base(path), ".jsonl")
|
||||
}
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
Reference in new issue
Block a user