Skip to content

Commit d340206

Browse files
committed
feat(serve): the defaulted conversation store is ephemeral — wiped each run
The default conversations.kv is a per-run scratch cache (every conversation is rebuildable by one prompt replay), but the append-only filestore never compacts: per-turn tail-block rewrites accumulate dead chunk versions forever — 1.5GB on a dev box from bench traffic alone. Serve now distinguishes the two lifetimes by who named the store. An unset -state-store means serve made the file: it is created FRESH at launch (filestore.Create's O_TRUNC clears whatever a previous run or crash left) and removed at shutdown. Any explicit -state-store path — even the default's literal location — is durable per-project state, opened in place and never wiped; that is the coming state-per-project surface: just set the path. generate is untouched: stateless runs open no store, -state keeps its own agent.kv. wireContinuity returns the cleanup RunServe defers (close + remove when ephemeral); the degraded no-enabler path returns a callable no-op so the unconditional defer is safe. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 6574440 commit d340206

3 files changed

Lines changed: 176 additions & 22 deletions

File tree

go/cmd/lem/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write
3636
printAdminToken := fs.Bool("print-admin-token", false, "print the admin Bearer token and exit (generates if absent, mode 0600 at ~/Lethean/lem/admin.token)")
3737
rotateAdminToken := fs.Bool("rotate-admin-token", false, "regenerate the admin Bearer token, print it, and exit")
3838
stateConversations := fs.Bool("state-conversations", true, "conversation continuity: wake each chat from its slept state, append only the new turn, sleep after — no prompt replay (disable with -state-conversations=false)")
39-
stateStorePath := fs.String("state-store", "", "conversation state store file (default ~/Lethean/lem/state/conversations.kv)")
39+
stateStorePath := fs.String("state-store", "", "conversation state store file for durable per-project state; unset = an ephemeral store at ~/Lethean/lem/state/conversations.kv, wiped fresh each serve run")
4040
nativeBackend := fs.Bool("native", false, "serve via the no-cgo native token-loop contract (the default go-inference metal engine already is native)")
4141
fs.Usage = func() {
4242
name := cliName()

go/serving/serve.go

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ type ServeConfig struct {
5555

5656
// Conversation continuity.
5757
StateConversations bool // wake each chat from its slept state, no prompt replay
58-
StateStorePath string // state store file (default ~/Lethean/lem/state/conversations.kv)
58+
StateStorePath string // durable per-project store file; empty = ephemeral default, wiped each serve run
5959

6060
// HTTP + admin.
6161
ReadTimeout time.Duration
@@ -117,9 +117,12 @@ func RunServe(ctx context.Context, cfg ServeConfig) error {
117117
}
118118

119119
// Conversation continuity — on by default. Any failure here degrades to
120-
// stateless serving with an honest notice; it never blocks the serve.
120+
// stateless serving with an honest notice; it never blocks the serve. The
121+
// cleanup closes the store at shutdown and clears the DEFAULTED store's
122+
// file (per-run scratch); an explicit -state-store survives as the durable
123+
// per-project state.
121124
if cfg.StateConversations {
122-
wireContinuity(ctx, cfg, hotSwap, log)
125+
defer wireContinuity(ctx, cfg, hotSwap, log)()
123126
}
124127

125128
if armDrafter {
@@ -188,52 +191,80 @@ func RunServe(ctx context.Context, cfg ServeConfig) error {
188191
// wireContinuity opens the conversation state store and registers the per-load
189192
// continuity hook. When no ContinuityEnabler is injected (the registered engine
190193
// exposes no continuity attach), or the store can't open, serve degrades to
191-
// stateless with an honest notice rather than failing.
192-
func wireContinuity(ctx context.Context, cfg ServeConfig, hotSwap *hotSwapResolver, log io.Writer) {
194+
// stateless with an honest notice rather than failing. The returned cleanup
195+
// (never nil) runs at serve shutdown: it closes the store, and when the store
196+
// was the DEFAULTED one it removes the file — the default store is a per-run
197+
// scratch cache, not an archive.
198+
func wireContinuity(ctx context.Context, cfg ServeConfig, hotSwap *hotSwapResolver, log io.Writer) func() {
193199
if cfg.EnableContinuity == nil {
194200
printServe(log, "serve: conversation continuity unavailable on this engine — serving stateless")
195-
return
196-
}
197-
storePath := core.Trim(cfg.StateStorePath)
198-
if storePath == "" {
199-
if homeR := core.UserHomeDir(); homeR.OK {
200-
if home, ok := homeR.Value.(string); ok {
201-
storePath = core.PathJoin(home, "Lethean", "lem", "state", "conversations.kv")
202-
}
203-
}
201+
return func() {}
204202
}
203+
storePath, ephemeral := resolveStateStorePath(cfg.StateStorePath)
205204
var store *filestore.Store
206205
if storePath != "" {
207-
if opened, err := openOrCreateStateStore(ctx, storePath); err == nil {
206+
if opened, err := openConversationStore(ctx, storePath, ephemeral); err == nil {
208207
store = opened
209208
} else {
210209
printServe(log, "serve: conversation state store %s: %v", storePath, err)
211210
}
212211
}
213212
if store == nil {
214213
printServe(log, "serve: conversation continuity unavailable — serving stateless")
215-
return
214+
return func() {}
216215
}
217216
enable := cfg.EnableContinuity
218217
hotSwap.setOnLoad(func(model inference.TextModel) {
219218
if err := enable(model, store); err != nil {
220219
printServe(log, "serve: conversation continuity unavailable (stateless serving continues): %v", err)
221220
return
222221
}
222+
if ephemeral {
223+
printServe(log, "serve: conversation continuity ON — chats wake from %s (ephemeral: wiped each serve run; set -state-store for durable per-project state), no prompt replay", storePath)
224+
return
225+
}
223226
printServe(log, "serve: conversation continuity ON — chats wake from %s, no prompt replay", storePath)
224227
})
228+
return func() {
229+
_ = store.Close()
230+
if ephemeral {
231+
core.Remove(storePath)
232+
}
233+
}
225234
}
226235

227-
// openOrCreateStateStore opens an existing conversation state store or creates a
228-
// fresh one (with its parent directory) at path. Ported from lthn-mlx's serve
229-
// state wiring.
230-
func openOrCreateStateStore(ctx context.Context, path string) (*filestore.Store, error) {
236+
// resolveStateStorePath maps the -state-store flag to the store path and its
237+
// lifetime: an EMPTY flag means serve made the store itself — the defaulted
238+
// conversations.kv is ephemeral (wiped fresh at launch, removed at shutdown),
239+
// so per-turn tail-block rewrites never accumulate across runs. Any explicit
240+
// path — even the default's literal location — is the durable per-project
241+
// store and is never wiped.
242+
func resolveStateStorePath(flagPath string) (path string, ephemeral bool) {
243+
if p := core.Trim(flagPath); p != "" {
244+
return p, false
245+
}
246+
if homeR := core.UserHomeDir(); homeR.OK {
247+
if home, ok := homeR.Value.(string); ok {
248+
return core.PathJoin(home, "Lethean", "lem", "state", "conversations.kv"), true
249+
}
250+
}
251+
return "", false
252+
}
253+
254+
// openConversationStore opens the conversation state store at path. An
255+
// ephemeral (defaulted) store is created FRESH every time — filestore.Create's
256+
// O_TRUNC wipes whatever a previous run (or crash) left behind. A durable
257+
// (explicit) store opens in place, created on first use.
258+
func openConversationStore(ctx context.Context, path string, ephemeral bool) (*filestore.Store, error) {
259+
if ephemeral {
260+
return filestore.Create(ctx, path)
261+
}
231262
if core.Stat(path).OK {
232263
return filestore.Open(ctx, path)
233264
}
234265
if dir := core.PathDir(path); dir != "" {
235266
if r := core.MkdirAll(dir, 0o755); !r.OK {
236-
return nil, core.E("serving.openOrCreateStateStore", "mkdir store dir", r.Value.(error))
267+
return nil, core.E("serving.openConversationStore", "mkdir store dir", r.Value.(error))
237268
}
238269
}
239270
return filestore.Create(ctx, path)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package serving
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
core "dappco.re/go"
10+
"dappco.re/go/inference/model/state"
11+
)
12+
13+
// statSize returns path's byte size via core.Stat (os.FileInfo behind Result).
14+
func statSize(t *testing.T, path string) int64 {
15+
t.Helper()
16+
r := core.Stat(path)
17+
if !r.OK {
18+
t.Fatalf("stat %s: %v", path, r.Value)
19+
}
20+
return r.Value.(interface{ Size() int64 }).Size()
21+
}
22+
23+
// TestResolveStateStorePath_Ephemeral_Good pins the defaulted lane: an empty
24+
// -state-store resolves to the home-relative conversations.kv AND marks it
25+
// ephemeral — serve made the store, so serve wipes it per run.
26+
func TestResolveStateStorePath_Ephemeral_Good(t *testing.T) {
27+
path, ephemeral := resolveStateStorePath("")
28+
if path == "" || !core.Contains(path, "conversations.kv") {
29+
t.Fatalf("default path = %q, want the home conversations.kv", path)
30+
}
31+
if !ephemeral {
32+
t.Fatal("defaulted store must be ephemeral")
33+
}
34+
}
35+
36+
// TestResolveStateStorePath_Explicit_Bad pins the durable lane: any explicit
37+
// path — even one equal to the default's literal location — is per-project
38+
// state and is never marked ephemeral.
39+
func TestResolveStateStorePath_Explicit_Bad(t *testing.T) {
40+
path, ephemeral := resolveStateStorePath("/tmp/project-a.kv")
41+
if path != "/tmp/project-a.kv" || ephemeral {
42+
t.Fatalf("explicit path = (%q, ephemeral=%v), want it kept verbatim and durable", path, ephemeral)
43+
}
44+
}
45+
46+
// TestResolveStateStorePath_Whitespace_Ugly pins trimming: a whitespace-only
47+
// flag is the unset flag, not a store named " ".
48+
func TestResolveStateStorePath_Whitespace_Ugly(t *testing.T) {
49+
path, ephemeral := resolveStateStorePath(" ")
50+
if !ephemeral || !core.Contains(path, "conversations.kv") {
51+
t.Fatalf("whitespace flag resolved to (%q, ephemeral=%v), want the ephemeral default", path, ephemeral)
52+
}
53+
}
54+
55+
// TestOpenConversationStore_EphemeralWipes_Good proves the launch wipe: a
56+
// store re-opened as ephemeral comes back EMPTY — whatever a previous run (or
57+
// crash) accumulated is gone.
58+
func TestOpenConversationStore_EphemeralWipes_Good(t *testing.T) {
59+
ctx := context.Background()
60+
path := t.TempDir() + "/conversations.kv"
61+
62+
first, err := openConversationStore(ctx, path, true)
63+
if err != nil {
64+
t.Fatalf("first open: %v", err)
65+
}
66+
if _, err := first.PutBytes(ctx, []byte("stale-turn-block"), state.PutOptions{}); err != nil {
67+
t.Fatalf("put: %v", err)
68+
}
69+
if err := first.Close(); err != nil {
70+
t.Fatalf("close: %v", err)
71+
}
72+
grown := statSize(t, path)
73+
74+
second, err := openConversationStore(ctx, path, true)
75+
if err != nil {
76+
t.Fatalf("second open: %v", err)
77+
}
78+
defer second.Close()
79+
wiped := statSize(t, path)
80+
if wiped >= grown {
81+
t.Fatalf("ephemeral reopen kept old bytes: %d -> %d, want a fresh (smaller) store", grown, wiped)
82+
}
83+
}
84+
85+
// TestOpenConversationStore_DurableSurvives_Bad proves the explicit lane keeps
86+
// its bytes across reopens — per-project state is never wiped.
87+
func TestOpenConversationStore_DurableSurvives_Bad(t *testing.T) {
88+
ctx := context.Background()
89+
path := t.TempDir() + "/project.kv"
90+
91+
first, err := openConversationStore(ctx, path, false)
92+
if err != nil {
93+
t.Fatalf("first open: %v", err)
94+
}
95+
if _, err := first.PutBytes(ctx, []byte("durable-project-block"), state.PutOptions{}); err != nil {
96+
t.Fatalf("put: %v", err)
97+
}
98+
if err := first.Close(); err != nil {
99+
t.Fatalf("close: %v", err)
100+
}
101+
grown := statSize(t, path)
102+
103+
second, err := openConversationStore(ctx, path, false)
104+
if err != nil {
105+
t.Fatalf("second open: %v", err)
106+
}
107+
defer second.Close()
108+
kept := statSize(t, path)
109+
if kept != grown {
110+
t.Fatalf("durable reopen changed the file: %d -> %d, want untouched", grown, kept)
111+
}
112+
}
113+
114+
// TestWireContinuity_NilEnabler_Ugly pins the cleanup contract: even the
115+
// degraded (no-enabler) path returns a callable cleanup, so RunServe's
116+
// unconditional defer never panics.
117+
func TestWireContinuity_NilEnabler_Ugly(t *testing.T) {
118+
cleanup := wireContinuity(context.Background(), ServeConfig{}, newHotSwapResolver("", "", 0, nil), nil)
119+
if cleanup == nil {
120+
t.Fatal("wireContinuity returned a nil cleanup")
121+
}
122+
cleanup()
123+
}

0 commit comments

Comments
 (0)