fix(memories): upsert namespace before HTTP commit — fleet-wide memory-write outage #2517

Merged
agent-reviewer merged 1 commits from fix/memories-http-upsert-namespace into main 2026-06-10 08:38:18 +00:00
2 changed files with 120 additions and 3 deletions
+20 -1
View File
@@ -107,7 +107,6 @@ func (h *MemoriesHandler) withMemoryV2APIs(plugin memoryPluginAPI, resolver name
return h
}
// Commit handles POST /workspaces/:id/memories
// Stores a memory fact with a scope (LOCAL, TEAM, GLOBAL) and an optional
// namespace (defaults to "general"). Namespaces implement the Holaboss
@@ -213,6 +212,26 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
return
}
// Ensure the namespace row exists before the write — the plugin's
// CommitMemory contract is "namespace must already exist (auto-created
// by handler if not)" and memory_records carries an FK to
// memory_namespaces. The MCP tool path (mcp_tools_memory_v2.go) has
// always done this upsert; this HTTP path skipped it, so any workspace
// whose namespace row was never seeded (every workspace created after
// the Phase A2 backfill that only ever wrote through this surface —
// i.e. the runtime a2a commit_memory tool and the canvas) failed every
// write with `memory_records_namespace_fkey` (fleet-wide, 2026-06-10).
// UpsertNamespace is idempotent, so the extra round-trip on warm
// namespaces is a cheap no-op.
if _, err := h.memv2.plugin.UpsertNamespace(ctx, nsName, contract.NamespaceUpsert{Kind: kindFromNamespace(nsName)}); err != nil {
log.Printf(
"Commit memory namespace upsert error: workspace=%s scope=%s namespace=%s err_class=%T err=%q",
workspaceID, body.Scope, nsName, err, err,
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store memory"})
return
}
// Plugin write. The plugin owns its own embedding generation (FTS
// + vector indices are internal to memory_plugin schema), so we no
// longer call h.embed here — that becomes dead weight on this path
@@ -11,9 +11,9 @@ import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/namespace"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
@@ -97,6 +97,103 @@ func TestMemoriesCommit_Local_Success(t *testing.T) {
}
}
// TestMemoriesCommit_UpsertsNamespaceBeforeWrite pins the fleet-wide
// 2026-06-10 regression: the HTTP Commit path went straight to
// plugin.CommitMemory without ensuring the namespace row exists, so any
// workspace whose memory_namespaces row was never seeded (everything
// created after the Phase A2 backfill that only wrote through this
// surface — the runtime a2a commit_memory tool and the canvas) failed
// every write with memory_records_namespace_fkey. The MCP tool path has
// always upserted first; this asserts the HTTP path does too, and in
// the right order.
//
// MUTATION: drop the UpsertNamespace call in MemoriesHandler.Commit →
// calls slice misses "upsert" → RED (the exact production failure).
func TestMemoriesCommit_UpsertsNamespaceBeforeWrite(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
var calls []string
var upsertNS string
var upsertKind contract.NamespaceKind
plugin := &stubMemoryPlugin{
upsertFn: func(_ context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) {
calls = append(calls, "upsert")
upsertNS = name
upsertKind = body.Kind
return &contract.Namespace{Name: name, Kind: body.Kind}, nil
},
commitFn: func(_ context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
calls = append(calls, "commit")
return &contract.MemoryWriteResponse{ID: "mem-up-1", Namespace: ns}, nil
},
}
handler := NewMemoriesHandler().withMemoryV2APIs(
plugin,
memCommitResolver("ws-up", contract.NamespaceKindWorkspace),
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-up"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"content":"first ever memory","scope":"LOCAL"}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Commit(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if len(calls) != 2 || calls[0] != "upsert" || calls[1] != "commit" {
t.Errorf("namespace must be upserted BEFORE the write, got call order %v", calls)
}
if upsertNS != "workspace:ws-up" {
t.Errorf("upsert namespace = %q, want workspace:ws-up", upsertNS)
}
if upsertKind != contract.NamespaceKindWorkspace {
t.Errorf("upsert kind = %q, want %q", upsertKind, contract.NamespaceKindWorkspace)
}
}
// TestMemoriesCommit_UpsertError_500 — an upsert failure must surface as
// the same stable generic 500 (no plugin internals leaked) and must NOT
// proceed to the write.
func TestMemoriesCommit_UpsertError_500(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
commitCalled := false
plugin := &stubMemoryPlugin{
upsertFn: func(_ context.Context, _ string, _ contract.NamespaceUpsert) (*contract.Namespace, error) {
return nil, errors.New("plugin down")
},
commitFn: func(_ context.Context, ns string, _ contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
commitCalled = true
return &contract.MemoryWriteResponse{ID: "nope", Namespace: ns}, nil
},
}
handler := NewMemoriesHandler().withMemoryV2APIs(
plugin,
memCommitResolver("ws-up2", contract.NamespaceKindWorkspace),
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-up2"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"content":"x","scope":"LOCAL"}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Commit(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on upsert failure, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte("failed to store memory")) {
t.Errorf("error body must stay the stable generic message, got %s", w.Body.String())
}
if commitCalled {
t.Error("CommitMemory must not run when the namespace upsert failed")
}
}
func TestMemoriesCommit_Global_AsRoot(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -663,6 +760,7 @@ func TestCommitMemory_LocalScope_NoDelimiterEscape(t *testing.T) {
t.Errorf("LOCAL memory content should be stored verbatim, got %q (expected %q)", cap.Body.Content, content)
}
}
// ---------- MemoriesHandler: Update (PATCH) ----------
//
// Pin the full Update flow: namespace-only edit, content edit (LOCAL),
@@ -681,4 +779,4 @@ func TestCommitMemory_LocalScope_NoDelimiterEscape(t *testing.T) {
// Caller passes content + namespace identical to existing values:
// post-normalisation nothing changed. Return 200 with changed=false,
// no UPDATE, no audit row. Saves a round-trip + an audit-log entry on
// idempotent re-edits (e.g. user clicks Save without changing fields).
// idempotent re-edits (e.g. user clicks Save without changing fields).