From 1d9563ee379de6e4ea16f9777cca42787ea32f5c Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 19 Sep 2026 15:48:33 -0400 Subject: [PATCH 1/5] catalog: own and join lazy warm --- internal/catalog/catalog.go | 33 ++++++++++++-- internal/catalog/lazy_test.go | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index bafb516aa2..fae8f5d307 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -257,6 +257,10 @@ type Options struct { type Catalog struct { ready *rows resolve func() *rows + // cancel and warmDone give a lazy catalog ownership of its background + // discovery. Close cancels the fetch and joins the goroutine before returning. + cancel context.CancelFunc + warmDone chan struct{} // warm is the resolved value published the instant resolution finishes, so // a question that must not wait can still be answered once the answer // exists. [Catalog.rows] blocks on the future; [Catalog.rowsNow] reads this @@ -366,9 +370,14 @@ func loadOrFallback(ctx context.Context, options Options) (resolved *rows, err e // the surface is up, so the goroutine warms the value while the caller carries // on, and only a question that genuinely arrives first ever blocks. func LoadLazy(ctx context.Context, options Options) *Catalog { - resolved := &Catalog{warmed: make(chan struct{})} + warmCtx, cancel := context.WithCancel(ctx) + resolved := &Catalog{ + warmed: make(chan struct{}), + cancel: cancel, + warmDone: make(chan struct{}), + } resolve := sync.OnceValue(func() *rows { - loaded, _ := loadOrFallback(ctx, options) + loaded, _ := loadOrFallback(warmCtx, options) resolved.warm.Store(loaded) // The wait door ([Catalog.Warmed]) reads the close, not the value, and // the two land together so a caller that arrived between them would @@ -377,10 +386,23 @@ func LoadLazy(ctx context.Context, options Options) *Catalog { return loaded }) resolved.resolve = resolve - guard.Go("catalog/warm", func() { resolve() }) + guard.Go("catalog/warm", func() { + defer close(resolved.warmDone) + resolve() + }) return resolved } +// Close cancels and joins a lazy catalog warm. It is safe to call more than +// once. Eager and zero catalogs have no background work and return immediately. +func (c *Catalog) Close() { + if c == nil || c.cancel == nil { + return + } + c.cancel() + <-c.warmDone +} + // load resolves one catalog, and reports why the fetch failed when it spent the // network and did not land. A fresh cache that needed no fetch is not a failure // and answers nil. @@ -399,6 +421,11 @@ func load(ctx context.Context, options Options) (*rows, error) { // fetch refuses an empty listing itself, so a nil error here is always rows. models, err := fetch(ctx, options) + if err == nil { + // A transport may report success after cancellation. Closing the owner + // must still prevent that late response from changing persistent state. + err = ctx.Err() + } if err == nil { fetchedAt := now().UTC() if path != "" { diff --git a/internal/catalog/lazy_test.go b/internal/catalog/lazy_test.go index 1d7e4efbfb..db7275f307 100644 --- a/internal/catalog/lazy_test.go +++ b/internal/catalog/lazy_test.go @@ -2,8 +2,12 @@ package catalog import ( "context" + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "testing" "time" @@ -150,3 +154,85 @@ func TestIndexKeepsTheFirstRowForANormalizedID(t *testing.T) { t.Fatal("Supports must read the same row Model does") } } + +func TestCloseJoinsLateSuccessfulWarmWithoutWritingEitherHome(t *testing.T) { + oldHome := t.TempDir() + newHome := t.TempDir() + t.Setenv("CODEAF_HOME", oldHome) + + started := make(chan struct{}) + release := make(chan struct{}) + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + close(started) + <-release + return &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(catalogPayload)), + }, nil + })} + first := LoadLazy(context.Background(), Options{ + BaseURL: "https://catalog.example/v1", HTTPClient: client, + }) + <-started + t.Setenv("CODEAF_HOME", newHome) + + closed := make(chan struct{}) + go func() { + first.Close() + close(closed) + }() + select { + case <-closed: + t.Fatal("Close returned while the warm fetch was still running") + case <-time.After(50 * time.Millisecond): + } + close(release) + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("Close did not join the completed warm fetch") + } + assertEmptyTree(t, oldHome) + assertEmptyTree(t, newHome) + + second := LoadLazy(context.Background(), Options{ + BaseURL: "https://catalog.example/v1", + HTTPClient: catalogClient(t, http.StatusOK, catalogPayload, nil), + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if !second.Warmed(ctx) { + t.Fatal("a second launch did not warm") + } + second.Close() + entries, err := os.ReadDir(newHome) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("the second launch did not write its cache") + } +} + +func assertEmptyTree(t *testing.T, root string) { + t.Helper() + var found string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if path != root { + found = path + return filepath.SkipAll + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if found != "" { + t.Fatalf("late warm wrote %s", found) + } +} From 509f9e3b6b99b69902e77f7ab17d41b3b817138d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 19 Sep 2026 15:49:05 -0400 Subject: [PATCH 2/5] docs: note lazy catalog warm close behavior --- .../unreleased/1264.fixed-catalog-lazy-warm-close.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md diff --git a/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md b/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md new file mode 100644 index 0000000000..1fa12b1b73 --- /dev/null +++ b/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md @@ -0,0 +1,9 @@ +--- +kind: fixed +area: catalog +summary: Close now cancels and joins lazy catalog warming before it can write a late cache. +--- + +# Lazy catalog warming stops cleanly at close + +A process close now cancels and joins its in-flight lazy catalog warm. If a fetch succeeds after cancellation, catalog loading checks the canceled context before writing the cache, so no cache write can occur after close returns. A later process launch can still warm its catalog normally. From ad8148b26aa71c67350d2daf0874a003c1b52e47 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 19 Sep 2026 15:49:16 -0400 Subject: [PATCH 3/5] docs: conform catalog warm change entry --- .../unreleased/1264-catalog-lazy-warm-close.md | 13 +++++++++++++ .../1264.fixed-catalog-lazy-warm-close.md | 9 --------- 2 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 docs/changes/unreleased/1264-catalog-lazy-warm-close.md delete mode 100644 docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md diff --git a/docs/changes/unreleased/1264-catalog-lazy-warm-close.md b/docs/changes/unreleased/1264-catalog-lazy-warm-close.md new file mode 100644 index 0000000000..c1a4f1ac78 --- /dev/null +++ b/docs/changes/unreleased/1264-catalog-lazy-warm-close.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: closing a process cancels and joins lazy catalog warming before a late cache write +pr: 1264 +surface: [engine] +invalidates: + - "Lazy catalog warming could outlive process close and write its cache afterward, including beneath a home selected by a later launch. Close now cancels and joins the warm, and a fetch that succeeds after cancellation does not write the cache." + - "A later process launch still warms its catalog normally." +--- + +# Lazy catalog warming stops cleanly at close + +A process close now cancels and joins its in-flight lazy catalog warm. If a fetch succeeds after cancellation, catalog loading checks the canceled context before writing the cache, so no cache write can occur after close returns. A later process launch can still warm its catalog normally. diff --git a/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md b/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md deleted file mode 100644 index 1fa12b1b73..0000000000 --- a/docs/changes/unreleased/1264.fixed-catalog-lazy-warm-close.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -kind: fixed -area: catalog -summary: Close now cancels and joins lazy catalog warming before it can write a late cache. ---- - -# Lazy catalog warming stops cleanly at close - -A process close now cancels and joins its in-flight lazy catalog warm. If a fetch succeeds after cancellation, catalog loading checks the canceled context before writing the cache, so no cache write can occur after close returns. A later process launch can still warm its catalog normally. From d18ec8db263849c2d0e7ba1d176792c7ff484842 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 19 Sep 2026 16:08:09 -0400 Subject: [PATCH 4/5] chatv3: close process model catalog --- cmd/codeaf/chatv3_process.go | 19 ++++++++++++++++++- cmd/codeaf/engine.go | 11 +++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index f567f39955..968107a162 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -60,6 +60,10 @@ type v3Process struct { // Models is ONE lazy warm and one cache on disk. N catalogs would be N // network round trips for one answer. Models *catalog.Catalog + // processCtx is the lifetime shared by background work owned by this process. + // processStop closes that lifetime before closeAll joins each owned worker. + processCtx context.Context + processStop context.CancelFunc // Shelf holds Models until somebody asks /model for today's list, and the // refreshed catalog after (chatv3_modelshelf.go). The picker and the two // session readers that answer about a model somebody may have just picked @@ -180,7 +184,8 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) { discovery := catalog.Options{ BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, } - models := catalog.LoadLazy(context.Background(), discovery) + processCtx, processStop := context.WithCancel(context.Background()) + models := catalog.LoadLazy(processCtx, discovery) // A tier row that says auto is answered from this catalog (config.AutoModels): // the same non-blocking read, never a fetch, and set once at start-up. config.AutoModels = models.ModelsNow @@ -192,6 +197,8 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) { ProfileDir: settings.ProfileDir, UnreadProfileKeys: append([]string(nil), settings.UnreadProfileKeys...), Models: models, + processCtx: processCtx, + processStop: processStop, Shelf: shelf, Harnesses: subharness.Default(), Memory: v3Memory(settings.ProfileDir), @@ -412,6 +419,16 @@ func (p *v3Process) closeAll() { return } + // Cancel the process lifetime first, then join the catalog warm. Catalog.Close + // also cancels its derived context, and its join is what makes the promise + // that no cache write can happen after closeAll returns airtight. + if p.processStop != nil { + p.processStop() + } + if p.Models != nil { + p.Models.Close() + } + // The process starts its place sweep before it opens any shared state. Seal // its note before closing that state so a late error cannot write after // close; the walk itself is not waited on, so quit does not grow with the diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 5f3ffd68ef..1527d17b94 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -145,6 +145,7 @@ func runRemoteEngine(args []string) error { // its own. The close is the same door [v3Process.closeAll] uses; it drains // the queue before joining, so the last row is on disk as well. session.CloseUsage() + closeEngineProcess() return quietRefusal(err) } @@ -520,6 +521,7 @@ func runEngineHost(workspaceFlag, sessionFlag string) error { // (The refusal path above never booted a conversation, so its registry is // empty and this close is a no-op there. It is one owner door, not two.) session.CloseUsage() + closeEngineProcess() if errors.Is(err, enginehost.ErrHostRunning) { return nil } @@ -949,6 +951,15 @@ func openEngineProcess() (*v3Process, error) { return engineProcess.proc, engineProcess.err } +// closeEngineProcess closes the once-per-process resources after the serving +// road has returned. In particular, closeAll cancels and joins model discovery +// before the engine process can exit or its profile can be reused. +func closeEngineProcess() { + if engineProcess.proc != nil { + engineProcess.proc.closeAll() + } +} + // engineStandingItems and engineStandingSave are the two standing doors, or nil. // // NIL IS THE AMBIENT SIDE OFF AND IT IS NEVER A CLOSURE THAT FAILS, which is From ec9961df561bd03bcd6556ce4e6288de07dfb3fb Mon Sep 17 00:00:00 2001 From: codeaf Date: Sat, 19 Sep 2026 17:20:47 -0400 Subject: [PATCH 5/5] cmd/codeaf: the model-warm rows name a catalog warm that closeAll now joins The change note takes the PR number (1264 named a PR that has already landed). With the catalog warm owned, cancelled and joined through Models.Close, the chatv3/models and engine/models rows in the guard.Go inventory law no longer carry an open writer one package down: each names the waiter joined since #1179 and the internal/catalog warm that v3Process.closeAll now cancels and joins. chatv3/sweep-home is the one known-open row left. --- cmd/codeaf/chatv3_guard_class_test.go | 8 ++++---- ...lazy-warm-close.md => 1274-catalog-lazy-warm-close.md} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename docs/changes/unreleased/{1264-catalog-lazy-warm-close.md => 1274-catalog-lazy-warm-close.md} (98%) diff --git a/cmd/codeaf/chatv3_guard_class_test.go b/cmd/codeaf/chatv3_guard_class_test.go index 6655a7a077..65628a448a 100644 --- a/cmd/codeaf/chatv3_guard_class_test.go +++ b/cmd/codeaf/chatv3_guard_class_test.go @@ -14,8 +14,8 @@ import ( // TestV3ProcessGuardGoClass is the inventory law for goroutines started by a // v3 process or launch. A long-lived writer must identify both its stop and the // closeAll join. chatv3/models and engine/models name the process waiter, joined -// since #1179; the internal/catalog catalog/warm goroutine each one starts is a -// separate open writer one package down, owned by the catalog-warm-close cell. +// since #1179, and the internal/catalog warm each one starts, which closeAll now +// cancels and joins through Models.Close. // The remaining known-open row (chatv3/sweep-home) names the later cell that owns it; // deleting a row is part of landing that cell. func TestV3ProcessGuardGoClass(t *testing.T) { @@ -26,8 +26,8 @@ func TestV3ProcessGuardGoClass(t *testing.T) { "chatv3/standing": {kind: "joined writer", stop: "v3Process.standingStop", join: "v3Process.closeAll calls stopStandingTicks"}, "chatv3/standing-stop": {kind: "joined helper", stop: "standingStop is closed", join: "chatv3/standing closes standingDone after this helper returns"}, "pool/judge-sweep": {kind: "joined writer", stop: "pool errand context", join: "v3Process.closeAll calls stopPoolErrands"}, - "chatv3/models": {kind: "joined waiter, downstream catalog/warm still open", stop: "warmModels waiter on the pool errand context", join: "v3Process.closeAll calls stopPoolErrands, joining the waiter since #1179; the internal/catalog catalog/warm it starts is not yet joined, owned by the catalog-warm-close cell"}, - "engine/models": {kind: "joined waiter, downstream catalog/warm still open", stop: "warmModels waiter on the pool errand context", join: "v3Process.closeAll calls stopPoolErrands, joining the waiter since #1179; the internal/catalog catalog/warm it starts is not yet joined, owned by the catalog-warm-close cell"}, + "chatv3/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"}, + "engine/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"}, "chatv3/sweep-home": {kind: "KNOWN-OPEN writer", owner: "later home-sweep ownership cell (class target three)"}, "chatv3/background": {kind: "one-shot", stop: "repairBackgroundChecks returns after one bounded Drift/Install pass"}, "chatv3/once-questions": {kind: "one-shot", stop: "agent.Close closes the WatchQuestions channel consumed by the range"}, diff --git a/docs/changes/unreleased/1264-catalog-lazy-warm-close.md b/docs/changes/unreleased/1274-catalog-lazy-warm-close.md similarity index 98% rename from docs/changes/unreleased/1264-catalog-lazy-warm-close.md rename to docs/changes/unreleased/1274-catalog-lazy-warm-close.md index c1a4f1ac78..5ce9e7cdd4 100644 --- a/docs/changes/unreleased/1264-catalog-lazy-warm-close.md +++ b/docs/changes/unreleased/1274-catalog-lazy-warm-close.md @@ -1,7 +1,7 @@ --- kind: fixed title: closing a process cancels and joins lazy catalog warming before a late cache write -pr: 1264 +pr: 1274 surface: [engine] invalidates: - "Lazy catalog warming could outlive process close and write its cache afterward, including beneath a home selected by a later launch. Close now cancels and joins the warm, and a fetch that succeeds after cancellation does not write the cache."