From f246d59ae3f59c6d0980335a92a091fa3b689c7a Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:18:47 -0500 Subject: [PATCH 1/4] fix(store): persist runtime session leases --- internal/server/server.go | 7 +- internal/server/server_test.go | 60 ++++++++++++++++ internal/store/store.go | 62 ++++++++++------- internal/store/store_test.go | 121 +++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 25 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 3e554e656..9dd2eb4e5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -522,9 +522,14 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { if mode == "" { mode = store.SessionOwnershipShared } - if err := s.store.CreateSessionWithOwnershipMode(body.ID, body.Project, projectpkg.RuntimeWorktreeDirectory(body.Directory), mode); err != nil { + if err := s.store.StartSessionWithOwnershipMode(body.ID, body.Project, projectpkg.RuntimeWorktreeDirectory(body.Directory), mode); err != nil { var conflict *store.SessionProjectConflictError switch { + case errors.Is(err, store.ErrSessionAlreadyEnded): + jsonErrorWithFields(w, http.StatusConflict, err.Error(), map[string]any{ + "code": "session_already_ended", + "session_id": body.ID, + }) case errors.As(err, &conflict): jsonErrorWithFields(w, http.StatusConflict, err.Error(), map[string]any{ "code": "session_project_conflict", diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 11e4a6839..38e02fc37 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4101,3 +4101,63 @@ func TestListProjectsEndpointEmptyStore(t *testing.T) { t.Fatalf("expected empty successful listing, got count=%d projects=%v", body.Count, body.Projects) } } + +func TestHandleCreateSessionRenewsRuntimeLeaseAndRejectsEndedSession(t *testing.T) { + st := newServerTestStore(t) + h := New(st, 0).Handler() + body := `{"id":"runtime-http","project":"runtime-project","directory":"/runtime","ownership_mode":"project_owned"}` + + post := func() *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/sessions", strings.NewReader(body))) + return rec + } + + if rec := post(); rec.Code != http.StatusCreated { + t.Fatalf("initial POST /sessions = %d, want 201: %s", rec.Code, rec.Body.String()) + } + before, err := st.GetSession("runtime-http") + if err != nil { + t.Fatalf("get initial runtime session: %v", err) + } + if _, err := st.DB().Exec(`UPDATE sessions SET runtime_lease_expires_at = ? WHERE id = ?`, "2001-02-03 04:05:06", "runtime-http"); err != nil { + t.Fatalf("seed expired runtime lease: %v", err) + } + + if rec := post(); rec.Code != http.StatusCreated { + t.Fatalf("renewing POST /sessions = %d, want 201: %s", rec.Code, rec.Body.String()) + } + after, err := st.GetSession("runtime-http") + if err != nil { + t.Fatalf("get renewed runtime session: %v", err) + } + if after.StartedAt != before.StartedAt || after.Project != "runtime-project" || after.OwnershipMode != store.SessionOwnershipProjectOwned || after.EndedAt != nil { + t.Fatalf("renewed HTTP runtime session = %#v, want unchanged session identity", after) + } + var future int + if err := st.DB().QueryRow(`SELECT runtime_lease_expires_at > datetime('now') FROM sessions WHERE id = ?`, "runtime-http").Scan(&future); err != nil { + t.Fatalf("check renewed runtime lease: %v", err) + } + if future != 1 { + t.Fatal("renewing POST /sessions did not persist a future runtime lease") + } + + if err := st.EndSession("runtime-http", "complete"); err != nil { + t.Fatalf("end runtime session: %v", err) + } + rec := post() + if rec.Code != http.StatusConflict { + t.Fatalf("POST /sessions for ended runtime session = %d, want 409: %s", rec.Code, rec.Body.String()) + } + var response struct { + Code string `json:"code"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode ended-session response: %v", err) + } + if response.Code != "session_already_ended" || response.SessionID != "runtime-http" { + t.Fatalf("ended-session response = %#v", response) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 13d4a6659..7c29f66e0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -108,13 +108,14 @@ var ( // ─── Types ─────────────────────────────────────────────────────────────────── type Session struct { - ID string `json:"id"` - Project string `json:"project"` - OwnershipMode string `json:"ownership_mode,omitempty"` - Directory string `json:"directory"` - StartedAt string `json:"started_at"` - EndedAt *string `json:"ended_at,omitempty"` - Summary *string `json:"summary,omitempty"` + ID string `json:"id"` + Project string `json:"project"` + OwnershipMode string `json:"ownership_mode,omitempty"` + Directory string `json:"directory"` + StartedAt string `json:"started_at"` + EndedAt *string `json:"ended_at,omitempty"` + Summary *string `json:"summary,omitempty"` + RuntimeLeaseExpiresAt *string `json:"-"` } // SessionProjectConflictError identifies a strict registration that would reuse @@ -1134,7 +1135,8 @@ func (s *Store) migrate() error { directory TEXT NOT NULL, started_at TEXT NOT NULL DEFAULT (datetime('now')), ended_at TEXT, - summary TEXT + summary TEXT, + runtime_lease_expires_at TEXT ); CREATE TABLE IF NOT EXISTS observations ( @@ -1311,6 +1313,9 @@ func (s *Store) migrate() error { if err := s.addColumnIfNotExists("sessions", "ownership_mode", "TEXT"); err != nil { return err } + if err := s.addColumnIfNotExists("sessions", "runtime_lease_expires_at", "TEXT"); err != nil { + return err + } // Legacy rows remain unclassified unless their persisted identity proves a // deterministic manual-save owner. Never infer ownership from an ID alone. if _, err := s.execHook(s.db, ` @@ -2793,36 +2798,44 @@ func (s *Store) CreateSessionWithOwnershipMode(id, project, directory, mode stri }) } -// StartSession registers a new session or idempotently starts an active one. -// It refuses to reuse an ended session ID so MCP callers cannot silently strand -// later writes on a fallback session. +// runtimeSessionLeaseDuration bounds local runtime-session liveness. It is not +// synchronized and never changes a session's terminal state. +const runtimeSessionLeaseDuration = "+30 minutes" + +// StartSession registers a shared runtime session or renews its local lease. func (s *Store) StartSession(id, project, directory string) error { + return s.StartSessionWithOwnershipMode(id, project, directory, SessionOwnershipShared) +} + +// StartSessionWithOwnershipMode registers a runtime session or renews its local +// lease. It preserves the existing session identity and refuses to reopen an +// ended session; EndSession remains terminal truth. +func (s *Store) StartSessionWithOwnershipMode(id, project, directory, mode string) error { if err := validateSessionID(id); err != nil { return err } + if !validSessionOwnershipMode(mode) { + return fmt.Errorf("%w %q", ErrInvalidSessionOwnershipMode, mode) + } project, _ = NormalizeProject(project) if strings.TrimSpace(project) == "" { return ErrProjectRequired } return s.withTx(func(tx *sql.Tx) error { - ended, err := sessionEndedTx(tx, id) - if err != nil { - return err - } - if ended { - return ErrSessionAlreadyEnded - } existingProject, existingMode, found, err := sessionOwnershipTx(tx, id) if err != nil { return err } if found { + if mode == SessionOwnershipProjectOwned && existingProject != "" && existingProject != project { + return &SessionProjectConflictError{SessionID: id, OwnerProject: existingProject, RequestedProject: project} + } if err := sessionProjectWriteError(id, existingProject, existingMode, project); err != nil { return err } } - if err := s.startSessionTx(tx, id, project, directory, SessionOwnershipShared); err != nil { + if err := s.startSessionTx(tx, id, project, directory, mode); err != nil { return err } var persisted Session @@ -2893,14 +2906,14 @@ func (s *Store) EndSession(id string, summary string) error { func (s *Store) GetSession(id string) (*Session, error) { row := s.db.QueryRow( - `SELECT id, project, ifnull(ownership_mode, ''), directory, started_at, ended_at, summary FROM sessions WHERE id = ?`, id, + `SELECT id, project, ifnull(ownership_mode, ''), directory, started_at, ended_at, summary, runtime_lease_expires_at FROM sessions WHERE id = ?`, id, ) var sess Session // A database upgraded from the schema where sessions.project was nullable // still carries NULL ownership, so the column must be read as nullable or // every caller that inspects a legacy session dies on an opaque scan error. var project sql.NullString - if err := row.Scan(&sess.ID, &project, &sess.OwnershipMode, &sess.Directory, &sess.StartedAt, &sess.EndedAt, &sess.Summary); err != nil { + if err := row.Scan(&sess.ID, &project, &sess.OwnershipMode, &sess.Directory, &sess.StartedAt, &sess.EndedAt, &sess.Summary, &sess.RuntimeLeaseExpiresAt); err != nil { return nil, err } sess.Project = project.String @@ -8086,13 +8099,14 @@ func (s *Store) createSessionTx(tx *sql.Tx, id, project, directory, mode string) func (s *Store) startSessionTx(tx *sql.Tx, id, project, directory, mode string) error { result, err := s.execHook(tx, - `INSERT INTO sessions (id, project, ownership_mode, directory) VALUES (?, ?, ?, ?) + `INSERT INTO sessions (id, project, ownership_mode, directory, runtime_lease_expires_at) VALUES (?, ?, ?, ?, datetime('now', ?)) ON CONFLICT(id) DO UPDATE SET project = CASE WHEN ifnull(trim(sessions.project, ?), '') = '' THEN excluded.project ELSE sessions.project END, ownership_mode = CASE WHEN ifnull(trim(sessions.ownership_mode, ?), '') = '' THEN excluded.ownership_mode ELSE sessions.ownership_mode END, - directory = CASE WHEN trim(sessions.directory, ?) = '' THEN excluded.directory ELSE sessions.directory END + directory = CASE WHEN trim(sessions.directory, ?) = '' THEN excluded.directory ELSE sessions.directory END, + runtime_lease_expires_at = excluded.runtime_lease_expires_at WHERE sessions.ended_at IS NULL`, - id, project, mode, directory, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, + id, project, mode, directory, runtimeSessionLeaseDuration, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, ) if err != nil { return err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1deeb8bda..d7fc6b800 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -16274,3 +16274,124 @@ func TestLimitContextBytesUTF8AndSmallBudget(t *testing.T) { t.Fatalf("small budget output produced invalid UTF-8: %q", got) } } + +func TestRuntimeSessionRegistrationPersistsLocalLease(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "runtime-project") + + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/runtime", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("register runtime session: %v", err) + } + + session, err := s.GetSession("runtime-session") + if err != nil { + t.Fatalf("get runtime session: %v", err) + } + if session.RuntimeLeaseExpiresAt == nil || *session.RuntimeLeaseExpiresAt == "" { + t.Fatalf("runtime session lease = %v, want future expiry", session.RuntimeLeaseExpiresAt) + } + var future int + if err := s.DB().QueryRow(`SELECT runtime_lease_expires_at > datetime('now') FROM sessions WHERE id = ?`, "runtime-session").Scan(&future); err != nil { + t.Fatalf("check runtime lease: %v", err) + } + if future != 1 { + t.Fatalf("runtime session lease must be in the future, got %q", *session.RuntimeLeaseExpiresAt) + } +} + +func TestRuntimeSessionRegistrationRenewsWithoutChangingSessionIdentity(t *testing.T) { + s := newTestStore(t) + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/runtime", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("register runtime session: %v", err) + } + if _, err := s.DB().Exec(`UPDATE sessions SET started_at = ?, runtime_lease_expires_at = ? WHERE id = ?`, "2001-02-03 04:05:06", "2001-02-03 04:05:06", "runtime-session"); err != nil { + t.Fatalf("seed expired lease: %v", err) + } + + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/ignored", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("renew runtime session: %v", err) + } + + session, err := s.GetSession("runtime-session") + if err != nil { + t.Fatalf("get renewed runtime session: %v", err) + } + if session.StartedAt != "2001-02-03 04:05:06" || session.Project != "runtime-project" || session.OwnershipMode != SessionOwnershipProjectOwned || session.EndedAt != nil { + t.Fatalf("renewed runtime session = %#v, want original identity and active terminal state", session) + } + var future int + if err := s.DB().QueryRow(`SELECT runtime_lease_expires_at > datetime('now') FROM sessions WHERE id = ?`, "runtime-session").Scan(&future); err != nil { + t.Fatalf("check renewed lease: %v", err) + } + if future != 1 { + t.Fatalf("renewal did not replace expired runtime lease: %#v", session.RuntimeLeaseExpiresAt) + } +} + +func TestRuntimeSessionRegistrationRejectsEndedSessions(t *testing.T) { + s := newTestStore(t) + if err := s.StartSession("runtime-session", "runtime-project", "/runtime"); err != nil { + t.Fatalf("register runtime session: %v", err) + } + if err := s.EndSession("runtime-session", "complete"); err != nil { + t.Fatalf("end runtime session: %v", err) + } + before, err := s.GetSession("runtime-session") + if err != nil { + t.Fatalf("get ended runtime session: %v", err) + } + + if err := s.StartSession("runtime-session", "runtime-project", "/runtime"); !errors.Is(err, ErrSessionAlreadyEnded) { + t.Fatalf("renew ended runtime session error = %v, want ErrSessionAlreadyEnded", err) + } + after, err := s.GetSession("runtime-session") + if err != nil { + t.Fatalf("get ended runtime session after renewal: %v", err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("ended runtime session changed: before=%#v after=%#v", before, after) + } +} + +func TestCreateSessionDoesNotCreateRuntimeLease(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("manual-session", "manual-project", "/manual"); err != nil { + t.Fatalf("create manual session: %v", err) + } + + session, err := s.GetSession("manual-session") + if err != nil { + t.Fatalf("get manual session: %v", err) + } + if session.RuntimeLeaseExpiresAt != nil { + t.Fatalf("manual session lease = %q, want nil", *session.RuntimeLeaseExpiresAt) + } +} + +func TestRuntimeSessionLeaseStaysOutOfSyncAndExportPayloads(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "runtime-project") + if err := s.StartSession("runtime-session", "runtime-project", "/runtime"); err != nil { + t.Fatalf("register runtime session: %v", err) + } + + var mutationPayload string + if err := s.DB().QueryRow(`SELECT payload FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntitySession, "runtime-session").Scan(&mutationPayload); err != nil { + t.Fatalf("read runtime session mutation: %v", err) + } + if strings.Contains(mutationPayload, "runtime_lease_expires_at") { + t.Fatalf("sync mutation leaked runtime lease: %s", mutationPayload) + } + + exported, err := s.Export() + if err != nil { + t.Fatalf("export runtime session: %v", err) + } + exportPayload, err := json.Marshal(exported) + if err != nil { + t.Fatalf("marshal runtime export: %v", err) + } + if strings.Contains(string(exportPayload), "runtime_lease_expires_at") { + t.Fatalf("export leaked runtime lease: %s", exportPayload) + } +} From f5c8bb9f79da23b302f1144053ff39af0f191ce4 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:54:47 -0500 Subject: [PATCH 2/4] fix(session): renew runtime leases on activity --- internal/setup/plugins/opencode/engram.ts | 12 ++-- internal/store/store.go | 9 +++ internal/store/store_test.go | 40 +++++++++++ plugin/opencode/engram.test.mjs | 51 ++++++++++--- plugin/opencode/engram.ts | 12 ++-- plugin/pi/index.ts | 22 +++--- plugin/pi/test/index-source.test.mjs | 30 +++++++- plugin/pi/test/native-tool-contract.test.mjs | 75 +++++++++++++++++--- 8 files changed, 205 insertions(+), 46 deletions(-) diff --git a/internal/setup/plugins/opencode/engram.ts b/internal/setup/plugins/opencode/engram.ts index 541c49b15..29e16e8cc 100644 --- a/internal/setup/plugins/opencode/engram.ts +++ b/internal/setup/plugins/opencode/engram.ts @@ -516,10 +516,10 @@ export const Engram: Plugin = async (ctx) => { * * Silently skips sub-agent sessions (tracked in `subAgentSessions`). */ - async function ensureSession(sessionId: string): Promise { + async function ensureSession(sessionId: string, renew = false): Promise { if (disposed || !await ensureResolvedProject() || disposed) return false if (!sessionId || invalidSessions.has(sessionId) || closeRequestedSessions.has(sessionId) || closedSessions.has(sessionId)) return false - if (knownSessions.has(sessionId)) return true + if (!renew && knownSessions.has(sessionId)) return true // Do not register sub-agent sessions in Engram (issue #116). if (subAgentSessions.has(sessionId)) return false const inFlight = registeringSessions.get(sessionId) @@ -658,7 +658,7 @@ export const Engram: Plugin = async (ctx) => { // Only capture non-trivial prompts (>10 chars) if (finalContent.length > 10) { - const registered = await ensureSession(sessionId) + const registered = await ensureSession(sessionId, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (!registered || confirmedSessionID !== sessionId) return await engramFetch("/prompts", { @@ -684,7 +684,7 @@ export const Engram: Plugin = async (ctx) => { if (!authoritativeSessionID) { throw new Error(`gentle-engram could not resolve an authoritative OpenCode runtime session for ${input.tool}`) } - const registered = await ensureSession(authoritativeSessionID) + const registered = await ensureSession(authoritativeSessionID, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (confirmedSessionID !== authoritativeSessionID) { throw new Error(`gentle-engram could not resolve an authoritative OpenCode runtime session for ${input.tool}`) @@ -702,7 +702,7 @@ export const Engram: Plugin = async (ctx) => { // input.sessionID comes from OpenCode — always available const sessionId = await resolveAuthoritativeSessionID(input.sessionID) if (!sessionId) return - const registered = await ensureSession(sessionId) + const registered = await ensureSession(sessionId, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (!registered || confirmedSessionID !== sessionId) return toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1) @@ -832,7 +832,7 @@ export const Engram: Plugin = async (ctx) => { // Runtime compaction context must never cross session boundaries. If the // authoritative session cannot be resolved or registered, skip this // injection rather than falling back to project-wide manual context. - if (sessionId && await ensureSession(sessionId)) { + if (sessionId && await ensureSession(sessionId, true)) { const data = await engramFetch( `/context/compaction?session_id=${encodeURIComponent(sessionId)}` ) diff --git a/internal/store/store.go b/internal/store/store.go index 7c29f66e0..50af196de 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2827,6 +2827,7 @@ func (s *Store) StartSessionWithOwnershipMode(id, project, directory, mode strin if err != nil { return err } + identityRepaired := !found if found { if mode == SessionOwnershipProjectOwned && existingProject != "" && existingProject != project { return &SessionProjectConflictError{SessionID: id, OwnerProject: existingProject, RequestedProject: project} @@ -2834,10 +2835,18 @@ func (s *Store) StartSessionWithOwnershipMode(id, project, directory, mode strin if err := sessionProjectWriteError(id, existingProject, existingMode, project); err != nil { return err } + var existingDirectory string + if err := tx.QueryRow(`SELECT ifnull(directory, '') FROM sessions WHERE id = ?`, id).Scan(&existingDirectory); err != nil { + return err + } + identityRepaired = existingProject == "" || existingMode == "" || strings.TrimSpace(existingDirectory) == "" } if err := s.startSessionTx(tx, id, project, directory, mode); err != nil { return err } + if !identityRepaired { + return nil + } var persisted Session // sessions.project is read through ifnull() because a database upgraded from // the schema where the column was nullable still carries rows that identify no diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d7fc6b800..fb39e848f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -16328,6 +16328,46 @@ func TestRuntimeSessionRegistrationRenewsWithoutChangingSessionIdentity(t *testi } } +func TestRuntimeSessionRenewalSkipsLeaseOnlySyncMutationButJournalsIdentityRepair(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "runtime-project") + + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/runtime", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("register runtime session: %v", err) + } + countMutations := func() int { + t.Helper() + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ? AND op = ?`, SyncEntitySession, "runtime-session", SyncOpUpsert).Scan(&count); err != nil { + t.Fatalf("count session mutations: %v", err) + } + return count + } + if got := countMutations(); got != 1 { + t.Fatalf("new runtime session mutations = %d, want 1", got) + } + if _, err := s.DB().Exec(`UPDATE sync_mutations SET acked_at = datetime('now') WHERE entity = ? AND entity_key = ?`, SyncEntitySession, "runtime-session"); err != nil { + t.Fatalf("ack initial session mutation: %v", err) + } + + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/runtime", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("renew runtime session: %v", err) + } + if got := countMutations(); got != 1 { + t.Fatalf("lease-only renewal mutations = %d, want 1", got) + } + + if _, err := s.DB().Exec(`UPDATE sessions SET directory = '' WHERE id = ?`, "runtime-session"); err != nil { + t.Fatalf("seed blank runtime directory: %v", err) + } + if err := s.StartSessionWithOwnershipMode("runtime-session", "runtime-project", "/runtime", SessionOwnershipProjectOwned); err != nil { + t.Fatalf("repair runtime session identity: %v", err) + } + if got := countMutations(); got != 2 { + t.Fatalf("identity repair mutations = %d, want 2", got) + } +} + func TestRuntimeSessionRegistrationRejectsEndedSessions(t *testing.T) { s := newTestStore(t) if err := s.StartSession("runtime-session", "runtime-project", "/runtime"); err != nil { diff --git a/plugin/opencode/engram.test.mjs b/plugin/opencode/engram.test.mjs index acde9a0b9..3543569a8 100644 --- a/plugin/opencode/engram.test.mjs +++ b/plugin/opencode/engram.test.mjs @@ -490,7 +490,7 @@ test("registration enters the cache only after a successful acknowledgement", as await runtime.event("session.created", session("runtime")) assert.deepEqual(runtime.registeredIDs, ["runtime"]) - for (const expectedRegistrations of [2, 2]) { + for (const expectedRegistrations of [2, 3]) { const output = toolOutput(undefined) await runtime.before({ tool: "mem_save", sessionID: "runtime" }, output) assert.equal(output.args.session_id, "runtime") @@ -498,6 +498,39 @@ test("registration enters the cache only after a successful acknowledgement", as } }) +test("OpenCode activity renews a cached root session once per activity wave", async (t) => { + const renewal = deferredResponse() + const runtime = await createRuntime(t, { + registrationResponse: (attempt) => attempt === 2 ? renewal.handler() : httpResponse(), + }) + await runtime.event("session.created", session("runtime")) + assert.deepEqual(runtime.registeredIDs, ["runtime"], "session.created remains initial registration") + + const message = { message: {}, parts: [{ type: "text", text: "A sufficiently long root prompt" }] } + const first = runtime.chat({ sessionID: "runtime" }, message) + const started = await Promise.race([ + renewal.started.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 25)), + ]) + try { + assert.equal(started, true, "cached runtime activity must start a renewal request") + const second = runtime.chat({ sessionID: "runtime" }, message) + await Promise.resolve() + assert.deepEqual(runtime.registeredIDs, ["runtime", "runtime"], "concurrent activity shares the renewal flight") + + renewal.resolve(httpResponse()) + await Promise.all([first, second]) + await runtime.after({ tool: "Task", sessionID: "runtime" }, "A".repeat(60)) + + assert.deepEqual(runtime.registeredIDs, ["runtime", "runtime", "runtime"], "later non-Engram tool activity renews before use") + assert.equal(runtime.requests.filter(({ path }) => path === "/prompts").length, 2) + assert.equal(runtime.requests.filter(({ path }) => path === "/observations/passive").length, 1) + } finally { + renewal.resolve(httpResponse()) + await first + } +}) + test("write tool hook binds only the four attributed writes to authoritative runtime identity", () => { assert.match(source, /SESSION_ATTRIBUTED_WRITE_TOOLS = new Set\(\[[\s\S]*"mem_save"[\s\S]*"mem_save_prompt"[\s\S]*"mem_session_summary"[\s\S]*"mem_capture_passive"/) assert.match(source, /"tool.execute.before"/) @@ -521,7 +554,7 @@ test("qualified Engram write IDs inject the authoritative root session", async ( } assert.deepEqual(runtime.sessionGetIDs, ["root", "leaf"]) - assert.deepEqual(runtime.registeredIDs, ["root"], "a child must reuse its authoritative root") + assert.deepEqual(runtime.registeredIDs, ["root", "root", "root", "root"], "a child must renew the authoritative root, never register itself") }) test("subagent sessions resolve to the authoritative parent and never register themselves", () => { @@ -660,13 +693,13 @@ test("session.updated reparents a known leaf while deletion tombstones dominate const afterUpdate = toolOutput(undefined) await runtime.before({ tool: "mem_save", sessionID: "leaf" }, afterUpdate) assert.equal(afterUpdate.args.session_id, "new-root") - assert.deepEqual(runtime.registeredIDs, ["old-root", "new-root"]) + assert.deepEqual(runtime.registeredIDs, ["old-root", "new-root", "old-root", "new-root"]) await runtime.event("session.deleted", { id: "new-root" }) const deleted = toolOutput() await assertNoForward(runtime.before({ tool: "mem_save", sessionID: "leaf" }, deleted), deleted) assert.deepEqual(runtime.sessionGetIDs, []) - assert.deepEqual(runtime.registeredIDs, ["old-root", "new-root"], "deleted descendants must never revive") + assert.deepEqual(runtime.registeredIDs, ["old-root", "new-root", "old-root", "new-root"], "deleted descendants must never revive") }) test("deleting a leaf during its SDK lookup aborts without mutation or registration", async (t) => { @@ -982,7 +1015,7 @@ test("runtime hook rejects failed bindings, retries registration, and binds chil const subagent = toolOutput("sub") await runtime.before({ tool: "mem_session_summary", sessionID: "sub" }, subagent) assert.equal(subagent.args.session_id, "runtime") - assert.equal(runtime.registeredIDs.length, 2, "child must reuse the confirmed parent, not register itself") + assert.equal(runtime.registeredIDs.length, 3, "child must renew the confirmed parent, not register itself") const unresolved = toolOutput() let resolutionErrorMessage = "" @@ -993,7 +1026,7 @@ test("runtime hook rejects failed bindings, retries registration, and binds chil }) assert.notEqual(resolutionErrorMessage, registrationErrorMessage) assert.equal(unresolved.args.session_id, MODEL_SESSION_ID, "failed resolution must not forward MCP arguments") - assert.equal(runtime.registeredIDs.length, 2) + assert.equal(runtime.registeredIDs.length, 3) await runtime.event("session.created", { id: "orphan", parentID: "" }) const orphan = toolOutput(undefined) @@ -1001,7 +1034,7 @@ test("runtime hook rejects failed bindings, retries registration, and binds chil await runtime.event("session.updated", session("orphan", "runtime")) await runtime.before({ tool: "mem_capture_passive", sessionID: "orphan" }, orphan) assert.equal(orphan.args.session_id, "runtime", "a later authoritative mapping must remain retryable") - assert.equal(runtime.registeredIDs.length, 2) + assert.equal(runtime.registeredIDs.length, 4) }) test("a title-only session.created event registers an authoritative root", async (t) => { @@ -1011,7 +1044,7 @@ test("a title-only session.created event registers an authoritative root", async const output = toolOutput(undefined) await runtime.before({ tool: "mem_capture_passive", sessionID: "legitimate-root" }, output) assert.equal(output.args.session_id, "legitimate-root") - assert.deepEqual(runtime.registeredIDs, ["legitimate-root"]) + assert.deepEqual(runtime.registeredIDs, ["legitimate-root", "legitimate-root"]) assert.deepEqual(runtime.sessionGetIDs, [], "event-cached roots must not query the SDK") }) @@ -1081,7 +1114,7 @@ test("deleting a parent invalidates descendants and prevents later writes or re- await assert.rejects(runtime.before({ tool: "mem_session_summary", sessionID }, toolOutput(undefined)), RESOLUTION_ERROR) } - assert.deepEqual(runtime.registeredIDs, ["parent"], "invalid descendants must never re-register as top-level sessions") + assert.deepEqual(runtime.registeredIDs, ["parent", "parent"], "invalid descendants must never re-register as top-level sessions") }) test("plugin disposal closes registered roots, not children, and waits for session ends", async (t) => { diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index 541c49b15..29e16e8cc 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -516,10 +516,10 @@ export const Engram: Plugin = async (ctx) => { * * Silently skips sub-agent sessions (tracked in `subAgentSessions`). */ - async function ensureSession(sessionId: string): Promise { + async function ensureSession(sessionId: string, renew = false): Promise { if (disposed || !await ensureResolvedProject() || disposed) return false if (!sessionId || invalidSessions.has(sessionId) || closeRequestedSessions.has(sessionId) || closedSessions.has(sessionId)) return false - if (knownSessions.has(sessionId)) return true + if (!renew && knownSessions.has(sessionId)) return true // Do not register sub-agent sessions in Engram (issue #116). if (subAgentSessions.has(sessionId)) return false const inFlight = registeringSessions.get(sessionId) @@ -658,7 +658,7 @@ export const Engram: Plugin = async (ctx) => { // Only capture non-trivial prompts (>10 chars) if (finalContent.length > 10) { - const registered = await ensureSession(sessionId) + const registered = await ensureSession(sessionId, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (!registered || confirmedSessionID !== sessionId) return await engramFetch("/prompts", { @@ -684,7 +684,7 @@ export const Engram: Plugin = async (ctx) => { if (!authoritativeSessionID) { throw new Error(`gentle-engram could not resolve an authoritative OpenCode runtime session for ${input.tool}`) } - const registered = await ensureSession(authoritativeSessionID) + const registered = await ensureSession(authoritativeSessionID, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (confirmedSessionID !== authoritativeSessionID) { throw new Error(`gentle-engram could not resolve an authoritative OpenCode runtime session for ${input.tool}`) @@ -702,7 +702,7 @@ export const Engram: Plugin = async (ctx) => { // input.sessionID comes from OpenCode — always available const sessionId = await resolveAuthoritativeSessionID(input.sessionID) if (!sessionId) return - const registered = await ensureSession(sessionId) + const registered = await ensureSession(sessionId, true) const confirmedSessionID = await resolveAuthoritativeSessionID(input.sessionID) if (!registered || confirmedSessionID !== sessionId) return toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1) @@ -832,7 +832,7 @@ export const Engram: Plugin = async (ctx) => { // Runtime compaction context must never cross session boundaries. If the // authoritative session cannot be resolved or registered, skip this // injection rather than falling back to project-wide manual context. - if (sessionId && await ensureSession(sessionId)) { + if (sessionId && await ensureSession(sessionId, true)) { const data = await engramFetch( `/context/compaction?session_id=${encodeURIComponent(sessionId)}` ) diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index c729e5034..97ba3c066 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -376,9 +376,9 @@ function projectCurrentUnsupportedError(cwd: string): CurrentProjectResponse { }; } -async function ensureSessionBestEffort(sessionId: string, sessionProject = project): Promise { +async function ensureSessionBestEffort(sessionId: string, sessionProject = project, renew = false): Promise { try { - await ensureSession(sessionId, sessionProject); + await ensureSession(sessionId, sessionProject, engramFetch, renew); return true; } catch (error) { warnSessionProjectConflictOnce(error); @@ -791,13 +791,13 @@ function warnSessionProjectConflictOnce(error: unknown): void { warnEngramFailure("/sessions", error); } -async function ensureSession(sessionId: string, sessionProject = project, fetch: EngramFetcher = engramFetch): Promise { +async function ensureSession(sessionId: string, sessionProject = project, fetch: EngramFetcher = engramFetch, renew = false): Promise { const key = `${sessionProject}:${sessionId}`; if (!sessionId) return; if (knownSessions.has(`\u0000closing:${sessionId}`)) throw new Error(`Pi runtime session ${sessionId} is closing`); const conflict = sessionProjectConflict(sessionId, sessionProject); if (conflict) throw conflict; - if (knownSessions.has(key)) return; + if (!renew && knownSessions.has(key)) return; const existingRegistration = sessionRegistrationsInFlight.get(key); if (existingRegistration) return existingRegistration; @@ -1229,7 +1229,7 @@ async function callMemoryTool(toolName: string, params: Record, case "mem_save": { if (!requestedProject) requireResolvedProject(); const activeSessionId = runtimeSessionForWrite(); - await ensureSession(activeSessionId, activeProject, fetch); + await ensureSession(activeSessionId, activeProject, fetch, true); return fetch("/observations", { method: "POST", body: { @@ -1261,7 +1261,7 @@ async function callMemoryTool(toolName: string, params: Record, case "mem_save_prompt": { if (!requestedProject) requireResolvedProject(); const promptSessionId = runtimeSessionForWrite(); - await ensureSession(promptSessionId, activeProject, fetch); + await ensureSession(promptSessionId, activeProject, fetch, true); const response = await fetch<{ id: number }>("/prompts", { method: "POST", body: { session_id: promptSessionId, content: params.content, project: activeProject }, @@ -1271,7 +1271,7 @@ async function callMemoryTool(toolName: string, params: Record, case "mem_session_summary": { if (!requestedProject) requireResolvedProject(); const summarySessionId = runtimeSessionForWrite(); - await ensureSession(summarySessionId, activeProject, fetch); + await ensureSession(summarySessionId, activeProject, fetch, true); return fetch("/observations", { method: "POST", body: { @@ -1319,7 +1319,7 @@ async function callMemoryTool(toolName: string, params: Record, case "mem_capture_passive": { requireResolvedProject(); const passiveSessionId = runtimeSessionForWrite(); - await ensureSession(passiveSessionId, project, fetch); + await ensureSession(passiveSessionId, project, fetch, true); return fetch("/observations/passive", { method: "POST", body: { @@ -1492,7 +1492,7 @@ export default function registerEngram(pi: ExtensionAPI) { if (!sessionId || soleActiveRuntimeSessionID() !== sessionId) return; try { - await ensureSession(sessionId); + await ensureSession(sessionId, project, engramFetch, true); } catch (error) { warnEngramFailure("/sessions", error); return; @@ -1523,7 +1523,7 @@ export default function registerEngram(pi: ExtensionAPI) { return { systemPrompt }; } if (sessionId && finalContent && finalContent.length > 10) { - if (!(await ensureSessionBestEffort(sessionId)) || knownSessions.has(`\u0000closing:${sessionId}`)) return { systemPrompt }; + if (!(await ensureSessionBestEffort(sessionId, project, true)) || knownSessions.has(`\u0000closing:${sessionId}`)) return { systemPrompt }; const body: PromptBody = { session_id: sessionId, content: stripPrivateTags(truncate(finalContent, 2000)), @@ -1544,7 +1544,7 @@ export default function registerEngram(pi: ExtensionAPI) { await refreshProjectDetection(ctx.cwd); if (!sessionId || projectDetectionPending || projectResolutionError) return; - if (!(await ensureSessionBestEffort(sessionId)) || knownSessions.has(`\u0000closing:${sessionId}`)) return; + if (!(await ensureSessionBestEffort(sessionId, project, true)) || knownSessions.has(`\u0000closing:${sessionId}`)) return; toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1); if (event.result === undefined) return; diff --git a/plugin/pi/test/index-source.test.mjs b/plugin/pi/test/index-source.test.mjs index 37308897b..a9d31b749 100644 --- a/plugin/pi/test/index-source.test.mjs +++ b/plugin/pi/test/index-source.test.mjs @@ -354,7 +354,7 @@ function buildEnsureSessionForTest(engramFetch) { .replace("const body: SessionBody", "const body") .replace("let acknowledgement: unknown;", "let acknowledgement;"); const factory = new Function("knownSessions", "registeredSessionProjects", "sessionRegistrationsInFlight", "sessionRegistrationProjects", "sessionProjectConflict", "sessionProjectConflictFromResponse", "engramFetch", "project", "directory", ` - return async function ensureSession(sessionId, sessionProject = project, fetch = engramFetch) { + return async function ensureSession(sessionId, sessionProject = project, fetch = engramFetch, renew = false) { ${body} }; `); @@ -413,7 +413,7 @@ function sessionCtx(id, sink) { test("mem_session_summary accepts explicit project fallback", () => { assert.match(source, /mem_session_summary: Type\.Object\(\{[\s\S]*project: optionalString\("Optional project to use when automatic detection is unavailable"\)/); - assert.match(source, /case "mem_session_summary":[\s\S]*if \(!requestedProject\) requireResolvedProject\(\);[\s\S]*ensureSession\(summarySessionId, activeProject, fetch\)[\s\S]*project: activeProject/); + assert.match(source, /case "mem_session_summary":[\s\S]*if \(!requestedProject\) requireResolvedProject\(\);[\s\S]*ensureSession\(summarySessionId, activeProject, fetch, true\)[\s\S]*project: activeProject/); }); test("mem_save_prompt returns a prompt-scoped identity", () => { @@ -1335,6 +1335,30 @@ test("session registration requires acknowledgement and failed acknowledgement r assert.equal(calls, 2); }); +test("renewal bypasses the acknowledged session short-circuit while coalescing in-flight work", async () => { + let releaseRenewal; + const renewal = new Promise((resolve) => { releaseRenewal = resolve; }); + let calls = 0; + const { ensureSession, knownSessions } = buildEnsureSessionForTest(async () => { + calls += 1; + if (calls === 2) await renewal; + return { status: "created" }; + }); + + await ensureSession("runtime"); + assert.equal(knownSessions.has("engram:runtime"), true, "initial registration stays cached for identity ownership"); + + const firstRenewal = ensureSession("runtime", "engram", undefined, true); + await Promise.resolve(); + const secondRenewal = ensureSession("runtime", "engram", undefined, true); + assert.equal(calls, 2, "parallel renewal requests share one POST /sessions"); + + releaseRenewal(); + await Promise.all([firstRenewal, secondRenewal]); + await ensureSession("runtime", "engram", undefined, true); + assert.equal(calls, 3, "a later activity renews the cached runtime session"); +}); + test("session compaction strictly registers before forwarding its summary", () => { const compactStart = source.indexOf('pi.on("session_compact"'); const compactEnd = source.indexOf('\n pi.on("before_agent_start"', compactStart); @@ -1342,7 +1366,7 @@ test("session compaction strictly registers before forwarding its summary", () = assert.notEqual(compactEnd, -1, "session_compact handler end not found"); const compactHandler = source.slice(compactStart, compactEnd); - const registration = compactHandler.indexOf("await ensureSession(sessionId);"); + const registration = compactHandler.indexOf("await ensureSession(sessionId, project, engramFetch, true);"); const summaryPost = compactHandler.indexOf("await archiveCompactionSummary(sessionId, summary);"); assert.notEqual(registration, -1, "session_compact must await strict session registration"); assert.notEqual(summaryPost, -1, "session_compact summary post not found"); diff --git a/plugin/pi/test/native-tool-contract.test.mjs b/plugin/pi/test/native-tool-contract.test.mjs index 8bc6d384c..bdaca7da5 100644 --- a/plugin/pi/test/native-tool-contract.test.mjs +++ b/plugin/pi/test/native-tool-contract.test.mjs @@ -182,7 +182,7 @@ test("one Pi runtime session cannot capture prompts or passive observations acro const sessionProjects = calls .filter((call) => call.method === "POST" && call.path === "/sessions") .map((call) => call.body.project); - assert.deepEqual(sessionProjects, ["project-a"], "the adapter must not re-register one identity under project-b"); + assert.deepEqual(sessionProjects, ["project-a", "project-a"], "same-project activity renews without registering the identity under project-b"); assert.equal( calls.filter((call) => call.method === "POST" && call.path === "/prompts").length, 2, @@ -635,7 +635,7 @@ test("session-attributed Pi writes bind to acknowledged runtime identity and ret assert.notEqual(observationBodies[0].session_id, "model-invented"); await memSave.execute("call-3", params, undefined, undefined, ctx); - assert.equal(registrationAttempts, 2, "successful acknowledgement should be cached"); + assert.equal(registrationAttempts, 3, "later session-attributed activity should renew the cached runtime session"); const noRuntime = await memSave.execute( "call-4", @@ -646,7 +646,60 @@ test("session-attributed Pi writes bind to acknowledged runtime identity and ret ); assert.equal(noRuntime.isError, true); assert.match(noRuntime.content[0].text, /Pi runtime session ID is unavailable/); - assert.equal(registrationAttempts, 2, "missing runtime identity must not synthesize or register a session"); + assert.equal(registrationAttempts, 3, "missing runtime identity must not synthesize or register a session"); + }); + } finally { + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.ENGRAM_URL; + else process.env.ENGRAM_URL = originalUrl; + } +}); + +test("repeated Pi session-attributed writes renew the runtime lease and coalesce concurrent renewal", async () => { + const originalFetch = globalThis.fetch; + const originalUrl = process.env.ENGRAM_URL; + process.env.ENGRAM_URL = "http://127.0.0.1:17437"; + const renewalGate = deferred(); + let registrations = 0; + const observationBodies = []; + globalThis.fetch = async (url, init) => { + const path = new URL(url).pathname; + if (path === "/health") return { ok: true, async json() { return { status: "ok" }; } }; + if (path === "/project/current") return { ok: true, async json() { return { project: "pi", project_source: "dir_basename", project_path: ROOT }; } }; + if (path === "/sessions") { + registrations += 1; + if (registrations === 2) await renewalGate.promise; + return { ok: true, status: 201, async json() { return { status: "created" }; } }; + } + if (path === "/observations") { + observationBodies.push(JSON.parse(init.body)); + return { ok: true, status: 201, async json() { return { id: observationBodies.length }; } }; + } + return { ok: true, async json() { return {}; } }; + }; + + try { + await withPluginSandbox("engram-pi-contract-", async ({ sandbox }) => { + const { registeredTools, eventHandlers } = await loadPluginHarness(sandbox); + const memSave = registeredTools.get("mem_save"); + const ctx = runtimeContext("renewing-runtime-session"); + await eventHandlers.get("session_start")({}, ctx); + + const first = memSave.execute("renew-1", { title: "first", content: "one" }, undefined, undefined, ctx); + await new Promise((resolve) => setImmediate(resolve)); + const second = memSave.execute("renew-2", { title: "second", content: "two" }, undefined, undefined, ctx); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(registrations, 2, "session_start completes before concurrent activity shares one renewal request"); + + renewalGate.resolve(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(firstResult.isError, undefined); + assert.equal(secondResult.isError, undefined); + assert.equal(observationBodies.length, 2); + + const third = await memSave.execute("renew-3", { title: "third", content: "three" }, undefined, undefined, ctx); + assert.equal(third.isError, undefined); + assert.equal(registrations, 3, "later activity must renew before its attributed write"); }); } finally { globalThis.fetch = originalFetch; @@ -700,7 +753,7 @@ test("parallel first-use writes share one acknowledged registration and keep it assert.ok(writeRequests.every((request) => request.session_id === "parallel-success-session")); await memSave.execute("parallel-success-cached", { title: "cached", content: "three" }, undefined, undefined, ctx); - assert.equal(registrationAttempts, 1, "acknowledged registration must remain cached"); + assert.equal(registrationAttempts, 2, "later activity must renew the acknowledged registration"); assert.equal(writeRequests.length, 3); }); } finally { @@ -766,7 +819,7 @@ test("shared registration failure rejects parallel writes and a later call retri assert.equal(writeRequests.length, 1); await memSave.execute("parallel-failure-cached", { title: "cached", content: "four" }, undefined, undefined, ctx); - assert.equal(registrationAttempts, 2, "successful retry must remain cached"); + assert.equal(registrationAttempts, 3, "later activity must renew the successful retry"); assert.equal(writeRequests.length, 2); }); } finally { @@ -831,7 +884,7 @@ test("an opaque runtime session ID stays byte-identical through registration, co assert.equal(observationBodies[0].session_id, runtimeSessionId, "the write must use the exact runtime identity"); await eventHandlers.get("session_compact")({ summary: "compacted work" }, ctx); - assert.equal(sessionBodies.length, 1, "compaction must reuse the cached exact identity instead of registering again"); + assert.equal(sessionBodies.length, 2, "compaction must renew the cached exact identity before forwarding its summary"); const compactionSummary = observationBodies.find((body) => body.type === "session_summary"); assert.ok(compactionSummary, "compaction summary not forwarded"); assert.equal(compactionSummary.session_id, runtimeSessionId, "compaction must attribute the summary to the exact identity"); @@ -845,8 +898,8 @@ test("an opaque runtime session ID stays byte-identical through registration, co await eventHandlers.get("session_start")({}, ctx); const afterShutdown = await memSave.execute("exact-2", { title: "second", content: "two" }, undefined, undefined, ctx); assert.equal(afterShutdown.isError, undefined); - assert.equal(sessionBodies.length, 2, "shutdown must clear the cached entry so nothing is left behind"); - assert.equal(sessionBodies[1].id, runtimeSessionId, "re-registration must still use the exact runtime identity"); + assert.equal(sessionBodies.length, 3, "shutdown must clear the cached entry so nothing is left behind"); + assert.equal(sessionBodies[2].id, runtimeSessionId, "re-registration must still use the exact runtime identity"); const memSessionEnd = registeredTools.get("mem_session_end"); const explicitlyEnded = await memSessionEnd.execute("explicit-end", { id: runtimeSessionId }, undefined, undefined, ctx); @@ -858,7 +911,7 @@ test("an opaque runtime session ID stays byte-identical through registration, co await eventHandlers.get("session_start")({}, ctx); const afterExplicitEnd = await memSave.execute("exact-3", { title: "third", content: "three" }, undefined, undefined, ctx); assert.equal(afterExplicitEnd.isError, undefined); - assert.equal(sessionBodies.length, 3, "an explicitly ended session must re-register before later writes"); + assert.equal(sessionBodies.length, 4, "an explicitly ended session must re-register before later writes"); failSessionEndRequest = true; const failedExplicitEnd = await memSessionEnd.execute("failed-explicit-end", { id: runtimeSessionId }, undefined, undefined, ctx); @@ -868,14 +921,14 @@ test("an opaque runtime session ID stays byte-identical through registration, co await eventHandlers.get("session_start")({}, ctx); const afterFailedShutdown = await memSave.execute("exact-4", { title: "fourth", content: "four" }, undefined, undefined, ctx); assert.equal(afterFailedShutdown.isError, undefined, "a failed session end must not prevent cleanup"); - assert.equal(sessionBodies.length, 4, "failed shutdown delivery must still clear the registration cache"); + assert.equal(sessionBodies.length, 5, "failed shutdown delivery must still clear the registration cache"); await eventHandlers.get("session_shutdown")({}, ctx); assert.equal(sessionEndBodies.length, 4, "a timed-out shutdown must still send only one end request"); await eventHandlers.get("session_start")({}, ctx); const afterTimedOutShutdown = await memSave.execute("exact-5", { title: "fifth", content: "five" }, undefined, undefined, ctx); assert.equal(afterTimedOutShutdown.isError, undefined, "a timed-out shutdown must still clear the registration cache"); - assert.equal(sessionBodies.length, 5, "writes after a timed-out shutdown must re-register"); + assert.equal(sessionBodies.length, 6, "writes after a timed-out shutdown must re-register"); }); } finally { globalThis.fetch = originalFetch; From c669413e8f1dc93ba650c980afec5bf8442a006b Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:33:33 -0500 Subject: [PATCH 3/4] fix(store): prefer live runtime session leases --- DOCS.md | 8 ++-- docs/DOCTOR.md | 2 +- docs/PLUGINS.md | 2 + internal/diagnostic/checks.go | 2 +- internal/diagnostic/diagnostic_test.go | 58 +++++++++++++++++++++++- internal/mcp/mcp_test.go | 36 +++++++++++++++ internal/store/store.go | 58 ++++++++++++++++-------- internal/store/store_test.go | 63 ++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 25 deletions(-) diff --git a/DOCS.md b/DOCS.md index d6d9e9de5..18b483876 100644 --- a/DOCS.md +++ b/DOCS.md @@ -44,7 +44,7 @@ For other docs: The live schema is created and incrementally migrated by `Store.migrate` in [`internal/store/store.go`](internal/store/store.go); treat that migration as the source of authority when this summary and the database differ. -- **sessions** — `id` (TEXT PK), `project`, `ownership_mode`, `directory`, `started_at`, `ended_at`, `summary` +- **sessions** — `id` (TEXT PK), `project`, `ownership_mode`, `directory`, `started_at`, `ended_at`, `summary`, `runtime_lease_expires_at` (local-only runtime liveness; never synced or exported) - **observations** — `id` (INTEGER PK AUTOINCREMENT), `sync_id`, `session_id` (FK), `type`, `title`, `content`, `tool_name`, `project`, `scope`, `topic_key`, `normalized_hash`, `revision_count`, `duplicate_count`, `last_seen_at`, `pinned`, `review_after`, `expires_at`, `embedding`, `embedding_model`, `embedding_created_at`, `created_at`, `updated_at`, `deleted_at` - **observations_fts** — FTS5 virtual table synced via triggers (`title`, `content`, `tool_name`, `type`, `project`, `topic_key`) - **user_prompts** — `id` (INTEGER PK AUTOINCREMENT), `sync_id`, `session_id` (FK), `content`, `project`, `created_at`; **prompt_tombstones** records deleted prompt `sync_id`, `session_id`, `project`, and `deleted_at` @@ -133,9 +133,11 @@ For an accepted `POST /sync/mutations/push`, each future materialized cloud chun ### Sessions -- `POST /sessions` — Create session. Body: `{id, project, directory, ownership_mode?}` +- `POST /sessions` — Create or renew a runtime session. Body: `{id, project, directory, ownership_mode?}` - `ownership_mode` accepts `shared` or `project_owned`; when omitted it defaults to `shared`. + - A successful create or renewal writes a local 30-minute `runtime_lease_expires_at` without changing the persisted session identity. Leases are local liveness evidence only: they are neither synced nor exported. - A `project_owned` registration cannot reuse a session with a nonblank persisted project different from its requested project. It returns `409` with `{error, code:"session_project_conflict", session_id, owner_project, requested_project}` and does not mutate the session or local sync journal. Same-project registration remains idempotent; omitted or `shared` registration retains compatibility for shared sessions. + - An ended session is terminal: renewal returns `409` and never reopens it. `POST /sessions/{id}/end` remains the only endpoint that sets `ended_at`. - An invalid non-empty `ownership_mode` returns `400` and does not create a session. - `POST /sessions/{id}/end` — End session. Body: `{summary}` - `GET /sessions/recent` — Recent sessions. Query: `?project=X&all_projects=true&limit=N` @@ -864,7 +866,7 @@ Guardrails: - An unbacked explicit `project` fails loudly and does not create a new bucket. - If a non-empty `session_id` is supplied and no session exists, `mem_save` fails with a structured error and does not write. - If both explicit `project` and `session_id` are supplied, they must resolve to the same normalized project or `mem_save` fails with a structured error and does not write. -- An explicit `session_id` is authoritative. When a write omits it, Engram uses the current process directory only to narrow active non-manual runtime sessions for the resolved project. It attaches to a session only when exactly one candidate remains, uses the project manual-save session when none remain, and fails closed when multiple candidates remain rather than selecting by recency. Directory is not session identity; callers with concurrent sessions must supply `session_id`, end other active matching sessions, or save independently with `engram save "TITLE" "CONTENT" --project PROJECT --type TYPE --topic TOPIC_KEY`. The CLI fallback writes to an independent project manual-save session and does not bind it to the current MCP session. Claude Code currently may require ending other active matching sessions because its MCP transport does not expose runtime identity to each tool call. +- An explicit `session_id` is authoritative. When a write omits it, Engram uses the current process directory only to narrow active non-manual runtime sessions for the resolved project. A valid, unexpired local lease takes precedence over legacy unleased rows in the same directory; every live leased owner remains a candidate, so multiple live leases fail closed. Expired, malformed, and nonblank invalid leases are excluded. Only when a directory has no live lease do unleased rows use the legacy seven-day effective-activity fallback (latest observation, then `started_at`). This precedence is applied independently for every requested directory. Engram attaches to a session only when exactly one candidate remains, uses the project manual-save session when none remain, and fails closed when multiple candidates remain rather than selecting by recency. Selection is read-only and never changes `ended_at`. Directory is not session identity; callers with concurrent sessions must supply `session_id`, end other active matching sessions, or save independently with `engram save "TITLE" "CONTENT" --project PROJECT --type TYPE --topic TOPIC_KEY`. The CLI fallback writes to an independent project manual-save session and does not bind it to the current MCP session. Claude Code currently may require ending other active matching sessions because its MCP transport does not expose runtime identity to each tool call. - `project_choice_reason=user_selected_after_ambiguous_project` is only honored when cwd resolution is actually ambiguous. On a non-ambiguous cwd, stale recovery flags do not override explicit-project precedence or session mismatch validation. - If ambiguous-project recovery is active, `project` must exactly match one of the previously returned `available_projects`; invented or normalized guesses are rejected. - Exact ambiguous-project choices can still fail with `project_name_collision` when multiple available names collapse to the same stored project bucket after normalization. Rename or disambiguate the colliding projects before retrying. diff --git a/docs/DOCTOR.md b/docs/DOCTOR.md index cf126ebdc..ca8cc35e3 100644 --- a/docs/DOCTOR.md +++ b/docs/DOCTOR.md @@ -61,7 +61,7 @@ The CLI `--json` and MCP tool return: - `session_project_directory_mismatch` — warns when `sessions.project` disagrees with the project inferred from trusted repository evidence for the session directory. A known exact `manual-save-{project}` target takes precedence over directory inference, so it does not produce this competing finding. Unknown manual suffixes and non-manual sessions retain normal trusted-directory behavior. The MVP trusts `git_remote` and `git_root` only; it ignores basename fallback, ambiguous workspaces, missing directories, and child-repo auto-promotion to avoid noisy false positives. - `manual_session_name_project_mismatch` — warns when a known `manual-save-{suffix}` session name disagrees with its persisted project. The suffix must normalize to a project already evidenced by a local session; a name alone never establishes `project_owned` ownership. -- `ambiguous_active_runtime_sessions` — warns once per project when two or more active runtime candidates match the same directory. Evidence contains the active-candidate count, involved directories, and session IDs. It uses the same unended, non-manual, seven-day activity-window rules as omitted-session resolution; doctor only reports the ambiguity and never selects, ends, or modifies sessions. Use an explicit session ID for writes in the affected directory. +- `ambiguous_active_runtime_sessions` — warns once per project when two or more active runtime candidates match the same directory. Evidence contains the active-candidate count, involved directories, and session IDs. It uses the same lease-aware selection as omitted-session resolution: valid unexpired local leases take precedence in their own directory, expired or malformed nonblank leases are excluded, and the legacy seven-day effective-activity window applies only when that directory has no live lease. Multiple live leases remain ambiguous. Doctor is diagnostic-only: it never selects, ends, or modifies sessions. End only confirmed stale IDs with `mem_session_end`; otherwise keep explicit runtime attribution with `session_id` on writes. - `sync_mutation_required_fields` — blocks when a pending `sync_mutations.payload` is missing required fields. On a device that uses cloud sync (at least one project enrolled), it also blocks when pending cloud mutations belong to a project that is not enrolled; the finding identifies the project and backlog count, so enroll intended projects with `engram cloud enroll ` or review enrollment before retrying. A local-only install with no enrolled project never reports that finding: any pending non-enrolled row there is legacy or otherwise pre-existing backlog, because new unenrolled local writes are not journaled. - `orphaned_observation_session` — warns when active or soft-deleted observations reference a missing session. Findings are grouped by the stored observation project and session ID. The canonical session cannot be reconstructed automatically, so inspect and recover the data deliberately; no supported repair exists. - `unowned_session_project` — warns for each session with an unclassified or invalid ownership mode, including blank persisted projects and contradictory legacy manual-save identities. Doctor never guesses a rescue. Use `engram projects rescue-ownership --project --session ` only after review; its apply path creates a SQLite backup that can be restored for rollback. The listing is deliberately unscoped. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index e940b1e77..fc4eda827 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -22,6 +22,8 @@ | Codex | Codex plugin assets under `plugin/codex/`; `engram setup codex` best-effort installs the marketplace plugin and writes MCP/instruction config. | | Pi | Pi package under `plugin/pi/` exposes Pi-native HTTP memory tools and configures MCP through `pi-mcp-adapter`. | +Pi and OpenCode activity renews the local runtime lease through their existing session registration paths. This is local SQLite liveness only: it has no timer, cloud synchronization, or cross-machine coordination. + --- ## OpenCode Plugin diff --git a/internal/diagnostic/checks.go b/internal/diagnostic/checks.go index adbc98277..2a0045ef8 100644 --- a/internal/diagnostic/checks.go +++ b/internal/diagnostic/checks.go @@ -125,7 +125,7 @@ func (c AmbiguousActiveRuntimeSessionsCheck) Run(ctx context.Context, scope Scop Message: fmt.Sprintf("Project %q has %d active runtime session candidates across %d directory or directories.", project, len(ambiguousIDs), len(ambiguousDirectories)), Why: "Omitted-session writes fail closed when multiple active runtime sessions match the same project and directory, so doctor reports the ambiguity without selecting or changing a session.", Evidence: mustJSON(map[string]any{"project": project, "active_candidate_count": len(ambiguousIDs), "directories": ambiguousDirectories, "session_ids": ambiguousIDs}), - SafeNextStep: "Use an explicit session ID for writes in the affected directory; doctor does not select, end, or modify sessions.", + SafeNextStep: "Use `mem_session_end` to end only confirmed stale IDs; otherwise keep explicit runtime attribution with `session_id` on writes. Doctor is diagnostic-only and never selects, ends, or modifies sessions.", RequiresConfirmation: true, }) } diff --git a/internal/diagnostic/diagnostic_test.go b/internal/diagnostic/diagnostic_test.go index 6082fafa9..0f8c0c260 100644 --- a/internal/diagnostic/diagnostic_test.go +++ b/internal/diagnostic/diagnostic_test.go @@ -115,6 +115,7 @@ func TestAmbiguousActiveRuntimeSessionsCheck(t *testing.T) { type session struct { id, project, directory string ended bool + leased bool startedAt string } tests := []struct { @@ -182,14 +183,41 @@ func TestAmbiguousActiveRuntimeSessionsCheck(t *testing.T) { }, wantStatus: StatusOK, }, + { + name: "one live lease suppresses recent legacy candidate", + project: "engram", + sessions: []session{ + {id: "legacy-recent", project: "engram", directory: "/work/engram"}, + {id: "leased-current", project: "engram", directory: "/work/engram", leased: true}, + }, + wantStatus: StatusOK, + }, + { + name: "two live leases remain ambiguous", + project: "engram", + sessions: []session{ + {id: "leased-a", project: "engram", directory: "/work/engram", leased: true}, + {id: "leased-b", project: "engram", directory: "/work/engram", leased: true}, + }, + wantStatus: StatusWarning, + wantDirectories: []string{"/work/engram"}, + wantSessionIDs: []string{"leased-a", "leased-b"}, + wantCandidateCnt: 2, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := newDiagnosticTestStore(t) for _, session := range tt.sessions { - if err := s.CreateSession(session.id, session.project, session.directory); err != nil { - t.Fatalf("CreateSession(%q): %v", session.id, err) + var err error + if session.leased { + err = s.StartSession(session.id, session.project, session.directory) + } else { + err = s.CreateSession(session.id, session.project, session.directory) + } + if err != nil { + t.Fatalf("create session %q: %v", session.id, err) } if session.startedAt != "" { if _, err := s.DB().Exec(`UPDATE sessions SET started_at = ? WHERE id = ?`, session.startedAt, session.id); err != nil { @@ -235,6 +263,32 @@ func TestAmbiguousActiveRuntimeSessionsCheck(t *testing.T) { } } +func TestAmbiguousActiveRuntimeSessionsCheckSafeNextStepNamesSupportedRuntimeActions(t *testing.T) { + s := newDiagnosticTestStore(t) + for _, id := range []string{"leased-a", "leased-b"} { + if err := s.StartSession(id, "engram", "/work/engram"); err != nil { + t.Fatalf("start session %q: %v", id, err) + } + } + + report, err := NewRunner().RunOne(context.Background(), Scope{Store: s, Project: "engram"}, CheckAmbiguousActiveRuntimeSessions) + if err != nil { + t.Fatalf("RunOne: %v", err) + } + if report.Status != StatusWarning || len(report.Checks) != 1 || len(report.Checks[0].Findings) != 1 { + t.Fatalf("report=%+v, want one ambiguous-runtime warning", report) + } + next := report.Checks[0].Findings[0].SafeNextStep + for _, want := range []string{"mem_session_end", "only confirmed stale IDs", "explicit runtime attribution"} { + if !strings.Contains(next, want) { + t.Fatalf("SafeNextStep=%q, want %q", next, want) + } + } + if strings.Contains(next, "engram session") { + t.Fatalf("SafeNextStep promises an unavailable session CLI: %q", next) + } +} + func TestAmbiguousActiveRuntimeSessionsCheckPropagatesActiveSessionQueryFailure(t *testing.T) { s := newDiagnosticTestStore(t) if err := s.CreateSession("runtime-a", "engram", "/work/engram"); err != nil { diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index fd0b10210..3b9467c29 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -875,6 +875,42 @@ func TestHandleSaveResolvesActiveSessionFromStore(t *testing.T) { } } +func TestHandleSavePrefersCurrentLeasedRuntimeSessionOverRecentLegacySession(t *testing.T) { + s := newMCPTestStore(t) + originalWorkingDirectory := currentWorkingDirectory + currentWorkingDirectory = func() string { return "/work/engram" } + t.Cleanup(func() { currentWorkingDirectory = originalWorkingDirectory }) + directory := runtimeSessionDirectory("/work/engram") + + if err := s.CreateSession("legacy-yesterday", "engram", directory); err != nil { + t.Fatalf("create legacy session: %v", err) + } + if _, err := s.DB().Exec(`UPDATE sessions SET started_at = datetime('now', '-1 day') WHERE id = ?`, "legacy-yesterday"); err != nil { + t.Fatalf("backdate legacy session: %v", err) + } + if err := s.StartSession("leased-current", "engram", directory); err != nil { + t.Fatalf("start leased session: %v", err) + } + + res, err := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "title": "Lease-aware active session resolution", + "content": "The leased runtime owner supersedes the recent legacy root.", + "type": "bugfix", + "project": "engram", + }}}) + if err != nil || res.IsError { + t.Fatalf("save: err=%v text=%q", err, callResultText(t, res)) + } + + observations, err := s.RecentObservations("engram", "project", 1) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(observations) != 1 || observations[0].SessionID != "leased-current" { + t.Fatalf("omitted-session save attached to %#v, want leased-current", observations) + } +} + func TestHandleSaveBindsNestedWriteToSessionRegisteredAtRepositoryRoot(t *testing.T) { s := newMCPTestStore(t) repository := project.DetectProjectFull(".") diff --git a/internal/store/store.go b/internal/store/store.go index 50af196de..fd476896d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2950,20 +2950,19 @@ const activeRuntimeSessionWindow = "-7 days" // - Scope to the (normalized) project. // - Scope to the current runtime directory. // - Require ended_at IS NULL — ended sessions are never returned. -// - Require recent effective activity. ended_at IS NULL alone means "never -// closed", not "in use": a session whose process is long gone stays a -// candidate forever, and two such rows make resolution fail permanently -// for that project and directory (#1101). Effective activity is the last -// observation the session recorded, falling back to started_at when it -// recorded none. The sessions table carries no pid or heartbeat, so -// liveness is not observable; recency of recorded work is. +// - A nonblank runtime lease is authoritative: return it only while it is +// valid and unexpired. Expired or malformed leases are excluded. +// - A live lease suppresses unleased legacy candidates in its directory only. +// Where no live lease exists, preserve the legacy effective-activity window: +// the latest observation, falling back to started_at, must be recent. // - Exclude the manual-save fallback sessions (id LIKE 'manual-save%'); those // are created by the fallback path itself and must not be resolved as "the // active session", which would make resolution circular. // // ActiveRuntimeSessions returns active, non-manual sessions for a project and // runtime directories. The directories narrow candidates; callers must not -// treat them as session identity. +// treat them as session identity. Selection is read-only: it never ends or +// repairs historical rows. func (s *Store) ActiveRuntimeSessions(project string, directories ...string) ([]string, error) { project, _ = NormalizeProject(project) if project == "" { @@ -2987,16 +2986,39 @@ func (s *Store) ActiveRuntimeSessions(project string, directories ...string) ([] } rows, err := s.queryHook(s.db, ` - SELECT s.id - FROM sessions s - LEFT JOIN observations o ON o.session_id = s.id - WHERE LOWER(s.project) = ? - AND s.directory IN (`+strings.Join(placeholders, ", ")+`) - AND s.ended_at IS NULL - AND s.id NOT LIKE 'manual-save%' - GROUP BY s.id - HAVING COALESCE(MAX(o.created_at), s.started_at) >= datetime('now', '`+activeRuntimeSessionWindow+`') - ORDER BY s.id + WITH runtime_candidates AS ( + SELECT s.id, + s.directory, + s.runtime_lease_expires_at, + COALESCE(MAX(o.created_at), s.started_at) AS effective_activity + FROM sessions s + LEFT JOIN observations o ON o.session_id = s.id + WHERE LOWER(s.project) = ? + AND s.directory IN (`+strings.Join(placeholders, ", ")+`) + AND s.ended_at IS NULL + AND s.id NOT LIKE 'manual-save%' + GROUP BY s.id + ), live_leased_directories AS ( + SELECT directory + FROM runtime_candidates + WHERE runtime_lease_expires_at IS NOT NULL + AND runtime_lease_expires_at <> '' + AND datetime(runtime_lease_expires_at) > datetime('now') + GROUP BY directory + ) + SELECT candidate.id + FROM runtime_candidates candidate + WHERE (candidate.runtime_lease_expires_at IS NOT NULL + AND candidate.runtime_lease_expires_at <> '' + AND datetime(candidate.runtime_lease_expires_at) > datetime('now')) + OR ((candidate.runtime_lease_expires_at IS NULL OR candidate.runtime_lease_expires_at = '') + AND candidate.effective_activity >= datetime('now', '`+activeRuntimeSessionWindow+`') + AND NOT EXISTS ( + SELECT 1 + FROM live_leased_directories live + WHERE live.directory = candidate.directory + )) + ORDER BY candidate.id `, args...) if err != nil { return nil, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index fb39e848f..3f51c4fc4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -14897,6 +14897,69 @@ func TestActiveRuntimeSessionsStaleRowDoesNotBlockLiveSession(t *testing.T) { } } +func TestActiveRuntimeSessionsPrefersLiveLeasesPerDirectory(t *testing.T) { + s := newTestStore(t) + for _, session := range []struct { + id, directory string + leased bool + }{ + {id: "legacy-suppressed", directory: "/work/leased"}, + {id: "live-lease", directory: "/work/leased", leased: true}, + {id: "legacy-fallback", directory: "/work/legacy"}, + } { + var err error + if session.leased { + err = s.StartSession(session.id, "engram", session.directory) + } else { + err = s.CreateSession(session.id, "engram", session.directory) + } + if err != nil { + t.Fatalf("create %s: %v", session.id, err) + } + } + if _, err := s.DB().Exec(`UPDATE sessions SET started_at = datetime('now', '-1 day') WHERE id = 'legacy-suppressed'`); err != nil { + t.Fatalf("backdate suppressed legacy session: %v", err) + } + + ids, err := s.ActiveRuntimeSessions("engram", "/work/leased", "/work/legacy") + if err != nil { + t.Fatalf("ActiveRuntimeSessions: %v", err) + } + if !reflect.DeepEqual(ids, []string{"legacy-fallback", "live-lease"}) { + t.Fatalf("active IDs = %#v, want live lease plus other-directory legacy fallback", ids) + } + var endedAt *string + if err := s.DB().QueryRow(`SELECT ended_at FROM sessions WHERE id = 'legacy-suppressed'`).Scan(&endedAt); err != nil { + t.Fatalf("read suppressed legacy session: %v", err) + } + if endedAt != nil { + t.Fatalf("selection must not end suppressed legacy session, ended_at = %q", *endedAt) + } +} + +func TestActiveRuntimeSessionsExcludesExpiredOrInvalidLeasesAndKeepsLiveLeaseAmbiguity(t *testing.T) { + s := newTestStore(t) + for _, id := range []string{"live-lease-a", "live-lease-b", "expired-lease", "invalid-lease"} { + if err := s.StartSession(id, "engram", "/work/engram"); err != nil { + t.Fatalf("start %s: %v", id, err) + } + } + if _, err := s.DB().Exec(`UPDATE sessions SET runtime_lease_expires_at = ? WHERE id = ?`, "2000-01-01 00:00:00", "expired-lease"); err != nil { + t.Fatalf("expire lease: %v", err) + } + if _, err := s.DB().Exec(`UPDATE sessions SET runtime_lease_expires_at = ? WHERE id = ?`, "not-a-timestamp", "invalid-lease"); err != nil { + t.Fatalf("invalidate lease: %v", err) + } + + ids, err := s.ActiveRuntimeSessions("engram", "/work/engram") + if err != nil { + t.Fatalf("ActiveRuntimeSessions: %v", err) + } + if !reflect.DeepEqual(ids, []string{"live-lease-a", "live-lease-b"}) { + t.Fatalf("active IDs = %#v, want only genuinely live leased owners", ids) + } +} + func TestActiveRuntimeSessionsIgnoresManualSaveSessions(t *testing.T) { s := newTestStore(t) From 88c2db4c351c310e36879e683188d87a5e2621a9 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:11:01 -0500 Subject: [PATCH 4/4] fix(store): remove superseded session state helper --- internal/store/store.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/internal/store/store.go b/internal/store/store.go index fd476896d..f04a2fd8f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9478,18 +9478,6 @@ func sessionOwnershipTx(tx *sql.Tx, sessionID string) (project, mode string, fou return normalized, strings.TrimSpace(rawMode.String), true, nil } -func sessionEndedTx(tx *sql.Tx, sessionID string) (bool, error) { - var endedAt sql.NullString - err := tx.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - if err != nil { - return false, err - } - return endedAt.Valid, nil -} - func sessionProjectWriteError(sessionID, sessionProject, mode, requested string) error { if sessionProject == "" || sessionProject == requested || mode == SessionOwnershipShared { return nil