From afc32f4ce51a8c5f39febbc94d2e3188c335886b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 20:43:05 -0500 Subject: [PATCH 1/6] A blank conversation a collection holds is kept but never resumed Launch resumed a never-spoken conversation that a collection references, ahead of the last spoken one, because "kept" and "spoken" shared one predicate. The emptiness check now reads only content; references keep a blank out of both the resume and the reuse piles, and every removal path still refuses it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appblank.go | 2 +- cmd/codeaf/chatv3_layout.go | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) 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/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 From 07798ef1991531b70d7644ff94915b8ef6f82677 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 20:43:05 -0500 Subject: [PATCH 2/6] Collections show a held blank conversation as live after a restart The resolver built its view from agents.list, which leaves named blanks out, so collections.open and collections.expand called them deleted. A missing id is now looked up once on disk before it is called deleted. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcollections.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 8355d5d926..87adb1f330 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 agent id, 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. 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" @@ -581,8 +581,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 its saved folder, 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 @@ -599,6 +599,14 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { } a, ok := r.agents[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 { + a = appRowView(row) + } + r.agents[id] = a + } + if a.ID == "" { return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} } return a, nil From f0cb404e20edf6f2d4aedf86d7edbe6560b24141 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 20:43:05 -0500 Subject: [PATCH 3/6] Tests: held blanks are neither resumed nor called deleted Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcollectionkeep_test.go | 253 ++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index 019c60feef..a79376ac74 100644 --- a/cmd/codeaf/appcollectionkeep_test.go +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -14,6 +14,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 +30,149 @@ 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: "an unreferenced blank beside an older spoken conversation", spoken: true}, + {name: "only an unreferenced blank"}, + {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) + } + kept := test.held || test.unreadable || test.team + if kept { + if len(empty) != 0 { + t.Fatalf("a kept blank was offered for reuse: %+v", empty) + } + } else if len(empty) != 1 || empty[0].dir != blank { + t.Fatalf("unreferenced blank candidates: %+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() + } else if !kept { + want = session.Place{Dir: blank}.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}}, "") + _, err := os.Stat(blank) + if kept && err != nil { + t.Fatalf("cleanup removed a kept blank: %v", err) + } + if !kept && !os.IsNotExist(err) { + t.Fatalf("cleanup kept an unreferenced blank: %v", err) + } + }) + } +} + +func TestABlankConversationCanBeReapedAfterItsLastCollectionReferenceIsRemoved(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "reserved-agent", "", time.Time{}) + 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) != 0 || len(empty) != 0 || v3EmptySession(dir) { + t.Fatal("a held blank was offered for resume or cleanup") + } + if err := store.Remove(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty = v3ScanBucket(bucket) + if len(spoken) != 0 || len(empty) != 1 || empty[0].dir != dir || !v3EmptySession(dir) { + t.Fatalf("a detached blank was still kept: spoken=%+v empty=%+v", spoken, empty) + } + folder, resumed, found := v3PickFolder(spoken, empty, false) + if !found || resumed || folder.dir != dir { + t.Fatalf("host discovery did not reuse the detached blank: %+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 +201,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 +345,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 +363,108 @@ 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) + } + }) + } +} + // Contract (g): a restart recovers every identity field from a never-spoken meta. func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { root, bucket := collectionKeepEnvironment(t) From 2fbeed6ba19ccdc68022bb9b37a8988506e4f297 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 20:43:22 -0500 Subject: [PATCH 4/6] Change entry for #2337 Co-Authored-By: Claude Opus 5.5 --- .../2337-held-blank-conversations-never-resumed.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md 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. From 48c7ac3657d2a8ba700f43da3a9633cbacc95ace Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 20:50:59 -0500 Subject: [PATCH 5/6] A permanently deleted conversation stays deleted in its collection The on-disk fallback found the metadata a permanent deletion keeps, so the resolver would have shown a deleted conversation as live and allowed a relink to it. A row pending deletion, or a folder with the deletion marker, now stays deleted. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcollectionkeep_test.go | 100 +++++++++++++++++++++++++++ cmd/codeaf/appcollections.go | 9 ++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index a79376ac74..cbfc63699e 100644 --- a/cmd/codeaf/appcollectionkeep_test.go +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -465,6 +465,106 @@ func TestACollectionHeldBlankAgentIsLiveAfterRestart(t *testing.T) { } } +// 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 87adb1f330..d0b780574a 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -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" ) @@ -601,8 +602,12 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { 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 { - a = appRowView(row) + 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.agents[id] = a } From 67f2cfd2ed9b52c2992df226376f88f756420c5a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 21:03:35 -0500 Subject: [PATCH 6/6] Only a conversation member uses the saved-folder fallback A blank found on disk shared the listing cache that task and process members use to find their owner, so a collection read could open a crew member's conversation to look for a child that does not exist. The fallback now serves conversation members only, from its own cache. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcollectionkeep_test.go | 222 +++++++++++++++++++++------ cmd/codeaf/appcollections.go | 44 ++++-- 2 files changed, 204 insertions(+), 62 deletions(-) diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index cbfc63699e..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" @@ -43,8 +44,6 @@ func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) {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: "an unreferenced blank beside an older spoken conversation", spoken: true}, - {name: "only an unreferenced blank"}, {name: "a blank held by both a team and a collection", held: true, team: true}, } { t.Run(test.name, func(t *testing.T) { @@ -98,13 +97,8 @@ func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) } else if len(spoken) != 0 { t.Fatalf("a blank became a resume candidate: %+v", spoken) } - kept := test.held || test.unreadable || test.team - if kept { - if len(empty) != 0 { - t.Fatalf("a kept blank was offered for reuse: %+v", empty) - } - } else if len(empty) != 1 || empty[0].dir != blank { - t.Fatalf("unreferenced blank candidates: %+v", empty) + 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 { @@ -117,59 +111,87 @@ func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) want := "" if test.spoken { want = session.Place{Dir: conversation}.Transcript() - } else if !kept { - want = session.Place{Dir: blank}.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}}, "") - _, err := os.Stat(blank) - if kept && err != nil { + if _, err := os.Stat(blank); err != nil { t.Fatalf("cleanup removed a kept blank: %v", err) } - if !kept && !os.IsNotExist(err) { - t.Fatalf("cleanup kept an unreferenced blank: %v", err) - } }) } } func TestABlankConversationCanBeReapedAfterItsLastCollectionReferenceIsRemoved(t *testing.T) { - _, bucket := collectionKeepEnvironment(t) - dir := writeV3Session(t, bucket, "reserved-agent", "", time.Time{}) - 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) != 0 || len(empty) != 0 || v3EmptySession(dir) { - t.Fatal("a held blank was offered for resume or cleanup") - } - if err := store.Remove(context.Background(), collection.ID, ref); err != nil { - t.Fatal(err) - } - spoken, empty = v3ScanBucket(bucket) - if len(spoken) != 0 || len(empty) != 1 || empty[0].dir != dir || !v3EmptySession(dir) { - t.Fatalf("a detached blank was still kept: spoken=%+v empty=%+v", spoken, empty) - } - folder, resumed, found := v3PickFolder(spoken, empty, false) - if !found || resumed || folder.dir != dir { - t.Fatalf("host discovery did not reuse the detached blank: %+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) + 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) + } + }) } } @@ -465,6 +487,108 @@ func TestACollectionHeldBlankAgentIsLiveAfterRestart(t *testing.T) { } } +// 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} { diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index d0b780574a..1d25860c2c 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -10,11 +10,11 @@ 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 -// folder lookup per unlisted agent id, 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. A member whose -// owner cannot be asked is "unknown", which says nothing about the member +// 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 ( @@ -493,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 @@ -527,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 } @@ -582,7 +583,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return appwire.Target{State: appwire.TargetDeleted} } -// agent is one agent off the listing or its saved folder, or the target a member +// 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 { @@ -598,7 +599,24 @@ 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] + 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. @@ -609,10 +627,10 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { a = appRowView(row) } } - r.agents[id] = a + r.savedAgents[id] = a } if a.ID == "" { - return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} + return appwire.Agent{}, not } return a, nil }