Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/DOCTOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>` 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 <name> --session <id>` only after review; its apply path creates a SQLite backup that can be restored for rollback. The listing is deliberately unscoped.
Expand Down
2 changes: 2 additions & 0 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/diagnostic/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
58 changes: 56 additions & 2 deletions internal/diagnostic/diagnostic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func TestAmbiguousActiveRuntimeSessionsCheck(t *testing.T) {
type session struct {
id, project, directory string
ended bool
leased bool
startedAt string
}
tests := []struct {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(".")
Expand Down
7 changes: 6 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading