diff --git a/cmd/codeaf/appblank.go b/cmd/codeaf/appblank.go index 7dd6e31fb9..90ab9cbff9 100644 --- a/cmd/codeaf/appblank.go +++ b/cmd/codeaf/appblank.go @@ -139,7 +139,7 @@ func (b *appBackend) reapBlank(a *appAgent) bool { } gone := false _ = teamstore.InspectConversation(b.crewProfile(), transcript, func(referenced bool) error { - if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) { + if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) || collectionKeepsConversation(dir) { return nil } gone = os.RemoveAll(dir) == nil diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index 019c60feef..41e282c152 100644 --- a/cmd/codeaf/appcollectionkeep_test.go +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "sync/atomic" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -29,6 +31,170 @@ func collectionKeepEnvironment(t *testing.T) (string, string) { return root, bucket } +// STAB2 E-B1 +func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) { + for _, test := range []struct { + name string + spoken bool + held bool + unreadable bool + team bool + }{ + {name: "a held blank beside an older spoken conversation", spoken: true, held: true}, + {name: "only a held blank", held: true}, + {name: "an unreadable store beside an older spoken conversation", spoken: true, unreadable: true}, + {name: "only a blank with an unreadable store", unreadable: true}, + {name: "a blank held by both a team and a collection", held: true, team: true}, + } { + t.Run(test.name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + blank := writeV3Session(t, bucket, "blank-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(blank, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + if test.spoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + } + path := home.Join("v3", "collections.db") + if test.held { + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(blank)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + } + if test.unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + if test.team { + transcript := session.Place{Dir: blank}.Transcript() + if err := teams.Save(config.ProfileDir(), []teams.Team{{ID: "aaaaaaaaaaaa", Name: "Notes", Manager: transcript, + Members: []teams.Member{{Key: transcript, File: transcript}}, + }}); err != nil { + t.Fatal(err) + } + } + + spoken, empty := v3ScanBucket(bucket) + if test.spoken { + if len(spoken) != 1 || spoken[0].dir != conversation { + t.Fatalf("resume candidates: %+v, want only %s", spoken, conversation) + } + } else if len(spoken) != 0 { + t.Fatalf("a blank became a resume candidate: %+v", spoken) + } + if len(empty) != 0 { + t.Fatalf("a kept blank was offered for reuse: %+v", empty) + } + folder, resumed, found := v3PickFolder(spoken, empty, true) + if test.spoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + want := "" + if test.spoken { + want = session.Place{Dir: conversation}.Transcript() + } + if got := v3LatestTranscript(root); got != want { + t.Fatalf("host discovery found %q, want %q", got, want) + } + // Recheck a stale cleanup candidate as well as the scan's own pile. + v3ReapEmpty([]v3Folder{{dir: blank}}, "") + if _, err := os.Stat(blank); err != nil { + t.Fatalf("cleanup removed a kept blank: %v", err) + } + }) + } +} + +func TestABlankConversationCanBeReapedAfterItsLastCollectionReferenceIsRemoved(t *testing.T) { + for _, withSpoken := range []bool{false, true} { + name := "only a detached blank" + if withSpoken { + name = "a detached blank beside an older spoken conversation" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "reserved-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(dir, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + wantSpoken := 0 + if withSpoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + wantSpoken = 1 + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(dir)} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty := v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 0 || v3EmptySession(dir) { + t.Fatalf("a held blank was offered for resume or cleanup: spoken=%+v empty=%+v", spoken, empty) + } + if err := store.Remove(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty = v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 1 || empty[0].dir != dir || !v3EmptySession(dir) { + t.Fatalf("a detached blank was still kept: spoken=%+v empty=%+v", spoken, empty) + } + want := dir + if withSpoken { + want = conversation + } + folder, resumed, found := v3PickFolder(spoken, empty, false) + if !found || resumed != withSpoken || folder.dir != want { + t.Fatalf("host discovery picked %+v, resumed=%t found=%t", folder, resumed, found) + } + if got := v3LatestTranscript(root); got != (session.Place{Dir: want}).Transcript() { + t.Fatalf("host discovery found %q, want the transcript in %s", got, want) + } + folder, resumed, found = v3PickFolder(spoken, empty, true) + if withSpoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + v3ReapEmpty(empty, "") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("cleanup kept a detached blank: %v", err) + } + }) + } +} + // Contract (g): collection-held blanks survive launch and the app's cleanup. func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { _, bucket := collectionKeepEnvironment(t) @@ -57,7 +223,10 @@ func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { if err != nil { t.Fatal(err) } - if v3EmptyUnassignedSession(held) || v3EmptySession(held) { + if !v3EmptyUnassignedSession(held) { + t.Fatal("a collection reference changed whether the conversation is blank") + } + if v3EmptySession(held) { t.Fatal("collection-held conversation is litter") } if !v3EmptyUnassignedSession(unheld) { @@ -198,7 +367,7 @@ func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { if err := os.WriteFile(path, bad, 0o600); err != nil { t.Fatal(err) } - if !collectionKeepsConversation(dir) || v3EmptyUnassignedSession(dir) { + if !collectionKeepsConversation(dir) || v3EmptySession(dir) { t.Fatal("uncertainty discarded a conversation") } got, err := os.ReadFile(path) @@ -216,6 +385,310 @@ func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { } } +// STAB2 E-M1 +func TestACollectionHeldBlankAgentIsLiveAfterRestart(t *testing.T) { + for _, title := range []string{"Reserved notes", ""} { + name := "a saved title" + if title == "" { + name = "no saved title" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: title, + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + return refuseAppOpen(ask) + }) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + checkUnlisted := func() { + t.Helper() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + } + checkUnlisted() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + member := open.Members[0] + if member.ID != id || member.Target.State != appwire.TargetLive || member.Target.Title != title { + t.Fatalf("collections.open member: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 0 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != title { + t.Fatalf("collections.expand member: %+v", live) + } + checkUnlisted() + if opened != 0 { + t.Fatalf("resolving collection members opened %d conversations", opened) + } + + missing := appwire.Ref{Kind: appwire.RefConversation, ID: "missing-agent"} + if _, err := b.AddToCollection(collection.ID, []appwire.Member{{Ref: missing}}); err != nil { + t.Fatal(err) + } + open, err = b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 2 { + t.Fatalf("collections.open with a missing agent: %+v, %v", open, err) + } + foundMissing := false + for _, member := range open.Members { + if member.ID == missing.ID { + foundMissing = true + if member.Target.State != appwire.TargetDeleted { + t.Fatalf("missing agent: %+v", member) + } + } + } + if !foundMissing { + t.Fatal("collections.open omitted the deleted member") + } + expanded, err = b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 || expanded.Refs[0].ID != id { + t.Fatalf("collections.expand with a missing agent: %+v, %v", expanded, err) + } + if opened != 0 { + t.Fatalf("resolving a missing member opened %d conversations", opened) + } + }) + } +} + +// STAB2-fix R4 +func TestACollectionReadNeverOpensAHeldBlankToResolveItsChildren(t *testing.T) { + for _, method := range []string{"collections.open", "collections.expand"} { + t.Run(method, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: "Reserved notes", + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + transcript := session.Place{Dir: dir}.Transcript() + if err := teams.Update(config.ProfileDir(), func(f *teams.File) error { + crew := teams.NewID() + f.Teams = append(f.Teams, teams.Team{ID: crew, Name: "Notes", Made: time.Now()}) + if err := f.AddMember(crew, teams.Member{Key: transcript, File: transcript, Where: root, Word: "notes", Handle: "notes"}); err != nil { + return err + } + return f.SetManager(crew, transcript) + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + refs := []workspace.Ref{ + {Kind: workspace.ConversationKind, ID: id}, + {Kind: workspace.TaskKind, ID: "1", SessionID: id}, + {Kind: workspace.ProcessKind, ID: id + ":p1", SessionID: id}, + } + // Reads visit newest members first, so resolve the conversation's + // fallback before either child asks for its owner. + if _, _, err := store.AddRefs(context.Background(), collection.ID, []workspace.Addition{ + {Ref: refs[0], AddedAt: 3}, + {Ref: refs[1], AddedAt: 2}, + {Ref: refs[2], AddedAt: 1}, + }); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + var opened atomic.Int32 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened.Add(1) + return refuseAppOpen(ask) + }) + t.Cleanup(func() { + _ = b.Close() + if got := opened.Load(); got != 0 { + t.Errorf("resolving collection members opened %d conversations", got) + } + }) + b.profileDir = config.ProfileDir() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + if method == "collections.open" { + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != len(refs) { + t.Fatalf("collections.open: %+v, %v", open, err) + } + for i, member := range open.Members { + if member.Kind != string(refs[i].Kind) || member.ID != refs[i].ID || member.SessionID != refs[i].SessionID { + t.Fatalf("collections.open member %d: %+v", i, member) + } + state := appwire.TargetDeleted + if i == 0 { + state = appwire.TargetLive + if member.Target.Title != "Reserved notes" || !reflect.DeepEqual(member.Target.In, []string{"Notes"}) { + t.Fatalf("held blank conversation: %+v", member) + } + } + if member.Target.State != state { + t.Fatalf("collections.open member: %+v, want %s", member, state) + } + } + } else { + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 2 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.Kind != appwire.RefConversation || live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != "Reserved notes" || !reflect.DeepEqual(live.Target.In, []string{"Notes"}) { + t.Fatalf("collections.expand member: %+v", live) + } + } + }) + } +} + +// STAB2-fix R1 +func TestAPermanentlyDeletedConversationStaysDeletedInItsCollection(t *testing.T) { + for _, pending := range []bool{false, true} { + name := "completed deletion" + if pending { + name = "pending deletion" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "deleted-agent" + dir := writeV3Session(t, bucket, id, "saved notes", time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC)) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + meta.Workspace, meta.Title = root, "Deleted notes" + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Saved conversations") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + transcript := session.Place{Dir: dir}.Transcript() + if pending { + // A staged deletion keeps its cleanup receipt and pending journal. + if err := os.WriteFile(filepath.Join(dir, ".delete-task-cleanup.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, teams.ConversationDeletedFile), []byte("deleted\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(transcript, transcript+".delete-pending"); err != nil { + t.Fatal(err) + } + if row, ok := session.ReadRows([]string{transcript})[filepath.Clean(transcript)]; !ok || !row.DeletionPending { + t.Fatalf("pending deletion fixture: %+v, found=%t", row, ok) + } + } else { + if err := session.DeleteConversationUnder(session.PlacesRoot(), config.ProfileDir(), transcript, nil, nil); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(transcript); !os.IsNotExist(err) { + t.Fatalf("permanent deletion kept the transcript: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, teams.ConversationDeletedFile)); err != nil { + t.Fatalf("permanent deletion lost its lifetime marker: %v", err) + } + if saved, err := session.LoadMeta(dir); err != nil || saved.ID != id || saved.Title != meta.Title { + t.Fatalf("permanent deletion lost its saved metadata: %+v, %v", saved, err) + } + + b := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + if member := open.Members[0]; member.ID != id || member.Target.State != appwire.TargetDeleted { + t.Fatalf("collections.open revived a permanently deleted conversation: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 0 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + + other, err := b.CreateCollection("Other conversations") + if err != nil { + t.Fatal(err) + } + from := appwire.Ref{Kind: appwire.RefConversation, ID: "another-agent"} + if _, err := b.AddToCollection(other.ID, []appwire.Member{{Ref: from}}); err != nil { + t.Fatal(err) + } + to := appwire.Ref{Kind: appwire.RefConversation, ID: id} + if _, err := b.RelinkCollection(other.ID, from, to); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("relink to a permanently deleted conversation: %v", err) + } + open, err = b.OpenCollection(other.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 || open.Members[0].ID != from.ID { + t.Fatalf("refused relink changed the collection: %+v, %v", open, err) + } + }) + } +} + // Contract (g): a restart recovers every identity field from a never-spoken meta. func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { root, bucket := collectionKeepEnvironment(t) diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 8355d5d926..1d25860c2c 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -10,12 +10,12 @@ package main // // RESOLUTION ASKS EACH OWNER ONCE PER CALL, NEVER ONCE PER MEMBER. A call // takes one agents listing (every state, closed and archived included), one -// crews listing, one task list and one process list per distinct agent, and -// one look for a git work tree per directory; a collection of a thousand -// files in one repository costs one walk up to its .git. No transcript is -// read to resolve anything, and a member whose owner cannot be asked is -// "unknown", which says nothing about the member itself, with a reason that -// names the owner and why it could not be asked. +// folder lookup per unlisted conversation id, one crews listing, one task list +// and one process list per distinct listed agent, and one look for a git work +// tree per directory; a collection of a thousand files in one repository costs +// one walk up to its .git. No transcript is read to resolve anything. A member +// whose owner cannot be asked is "unknown", which says nothing about the member +// itself, with a reason naming the owner and why it could not be asked. import ( "bufio" @@ -34,6 +34,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -492,9 +493,10 @@ func appUnaskedReason(owner string, err error) string { type collectionResolver struct { b *appBackend - agentsRead bool - agents map[string]appwire.Agent - agentsErr error + agentsRead bool + agents map[string]appwire.Agent + agentsErr error + savedAgents map[string]appwire.Agent crewsRead bool crews []appwire.Crew @@ -526,7 +528,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { } return r.factoryTargets[ref] case workspace.ConversationKind: - a, not := r.agent(ref.ID) + a, not := r.conversation(ref.ID) if not != nil { return *not } @@ -581,8 +583,8 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return appwire.Target{State: appwire.TargetDeleted} } -// agent is one agent off the one listing, or the target a member of it is -// when there is no such agent (deleted) or no listing (unknown, and why). +// agent is one agent off the listing, or the target a member +// of it is when there is no such agent (deleted) or no listing (unknown, and why). func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { if !r.agentsRead { r.agentsRead = true @@ -597,13 +599,42 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { t := appUnknown("the conversation list", r.agentsErr) return appwire.Agent{}, &t } - a, ok := r.agents[id] - if !ok { + a := r.agents[id] + if a.ID == "" { return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} } return a, nil } +// conversation also checks saved folders for explicitly referenced agents. +// Its cache stays separate so child references still require a listed owner. +func (r *collectionResolver) conversation(id string) (appwire.Agent, *appwire.Target) { + a, not := r.agent(id) + if not == nil || not.State != appwire.TargetDeleted { + return a, not + } + if r.savedAgents == nil { + r.savedAgents = map[string]appwire.Agent{} + } + a, ok := r.savedAgents[id] + if !ok { + // Named blanks stay out of agents.list but can still be held by a + // collection. Cache misses too, since several members can name one agent. + if row, found := appRowOnDisk(id); found && !row.DeletionPending { + // Permanent deletion retains metadata, but its lifetime marker + // prevents the saved folder from becoming a live target again. + if _, err := os.Stat(filepath.Join(row.Dir, teams.ConversationDeletedFile)); errors.Is(err, fs.ErrNotExist) { + a = appRowView(row) + } + } + r.savedAgents[id] = a + } + if a.ID == "" { + return appwire.Agent{}, not + } + return a, nil +} + // appFindOwned is one task or process of an agent, read with list once per // agent per call, or the target it is when it is not there; owner names the // list in an unknown target's reason. diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 2197ff6065..81ef673d68 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -677,8 +677,9 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { } } if v3EmptyUnassignedSession(dir) { - // A team's reference keeps it, and so does a store that cannot say. - if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced { + // A team or collection reference keeps a blank out of both piles. + // So does a store that cannot say whether it is referenced. + if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced && !collectionKeepsConversation(dir) { empty = append(empty, folder) } continue @@ -710,14 +711,17 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { // A team reference makes an empty conversation intentional, not reusable // litter. Only the teams that name this conversation are read (the store's // index), and a store that cannot say keeps the folder. +// A collection reference keeps it too, including when its store cannot be read. func v3EmptySession(dir string) bool { referenced, err := teams.ConversationReferenced(config.ProfileDir(), session.Place{Dir: dir}.Transcript()) if err != nil || referenced { return false } - return v3EmptyUnassignedSession(dir) + return v3EmptyUnassignedSession(dir) && !collectionKeepsConversation(dir) } +// v3EmptyUnassignedSession reads only content. References decide whether a blank +// is kept, never whether it is a conversation somebody has spoken in. func v3EmptyUnassignedSession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) @@ -731,7 +735,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !collectionKeepsConversation(dir) + return true } // v3ReapEmpty removes the empty session folders a launch left behind, except @@ -751,7 +755,7 @@ func v3ReapEmpty(empty []v3Folder, keep string) { } transcript := session.Place{Dir: folder.dir}.Transcript() _ = teams.InspectConversation(config.ProfileDir(), transcript, func(referenced bool) error { - if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) { + if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) && !collectionKeepsConversation(folder.dir) { _ = os.RemoveAll(folder.dir) } return nil diff --git a/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md new file mode 100644 index 0000000000..3d745df9fc --- /dev/null +++ b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: A blank conversation a collection holds is never resumed and reads as live in its collection +pr: 2337 +surface: [app, chat] +invalidates: [] +--- + +A never-spoken conversation that a collection references is kept, but a launch +without an explicit session no longer resumes it ahead of the last spoken +conversation, and `collections.open` no longer reports it as deleted after a +restart.