mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #13594 from SivanCola/codex/history-catalog-recovery-main-v2
Fix legacy recovery identity and catalog reconciliation lifetime / 修复历史恢复身份与目录校验生命周期
This commit is contained in:
9 files changed
+665
-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,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitRecoveryPrefixedTopicIdentityIsPreserved(t *testing.T) {
|
||||
rootPath := filepath.Join(t.TempDir(), "root.jsonl")
|
||||
group := agent.BranchID(rootPath)
|
||||
oldTopic := "recovery:" + group
|
||||
root := SessionRecord{Path: rootPath}
|
||||
leaf := SessionRecord{Path: filepath.Join(filepath.Dir(rootPath), "leaf.jsonl"), Recovered: true, ParentID: group, RecoveryGroupID: group, RecoveryCanonical: true}
|
||||
for _, placement := range []string{"root", "member", "rootless"} {
|
||||
t.Run(placement, func(t *testing.T) {
|
||||
parent, recovery := root, leaf
|
||||
if placement == "root" {
|
||||
parent.TopicID, parent.TopicTitle = oldTopic, "Persisted title"
|
||||
} else {
|
||||
recovery.TopicID, recovery.TopicTitle = oldTopic, "Persisted title"
|
||||
}
|
||||
records := []SessionRecord{parent, recovery}
|
||||
if placement == "rootless" {
|
||||
records = records[1:]
|
||||
}
|
||||
for _, got := range projectLogicalSessions(records) {
|
||||
if got.TopicID != oldTopic || got.LogicalTopicID != oldTopic || got.TopicTitle != "Persisted title" {
|
||||
t.Fatalf("explicit recovery-prefixed authority changed: %+v", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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