feat(core): fail-loud when platform concierge declared management MCP is not loaded #3101
@@ -0,0 +1,300 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestHeartbeatHandler_PlatformManagementMCPMissing_FlipsOnlineToDegraded
|
||||
// verifies core#3082 (CR2 #12653 fix): a platform concierge that reports
|
||||
// loaded_mcp_tools but does NOT include the literal required tool identifier
|
||||
// `mcp__molecule-platform__create_workspace` is marked degraded. The old
|
||||
// check compared the loaded tools against the plugin NAME
|
||||
// (`molecule-ai-plugin-molecule-platform-mcp`) which never matches the
|
||||
// namespaced tool ids Claude Code dispatches — that was the false-green.
|
||||
func TestHeartbeatHandler_PlatformManagementMCPMissing_FlipsOnlineToDegraded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Initial heartbeat UPDATE.
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-mcp-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online"))
|
||||
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-mcp-missing", 0.0, "", 0, 60, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// evaluateStatus: currentStatus=online, kind=platform.
|
||||
mock.ExpectQuery("SELECT status, kind, last_register_failure_at FROM workspaces WHERE id =").
|
||||
WithArgs("ws-mcp-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "kind", "last_register_failure_at"}).AddRow("online", "platform", nil))
|
||||
|
||||
// platformAgentHasModelSecret: model secret exists.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-mcp-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
// platformAgentManagementMCPLoaded: listDeclaredPlugins returns management MCP.
|
||||
mock.ExpectQuery("SELECT plugin_name, source_raw FROM workspace_declared_plugins").
|
||||
WithArgs("ws-mcp-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plugin_name", "source_raw"}).
|
||||
AddRow(conciergePlatformMCPName, "gitea://molecule-ai/molecule-ai-plugin-molecule-platform-mcp#main"))
|
||||
|
||||
// Degraded UPDATE — required tool absent.
|
||||
mock.ExpectExec("UPDATE workspaces SET status =.*status = 'online'").
|
||||
WithArgs(models.StatusDegraded, "platform agent management MCP declared but not loaded; marking degraded (core#3082)", "ws-mcp-missing").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// WORKSPACE_DEGRADED broadcast.
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// loaded_mcp_tools has plenty of tools but NOT the literal required one.
|
||||
body := `{"workspace_id":"ws-mcp-missing","error_rate":0.0,"sample_error":"","active_tasks":0,"uptime_seconds":60,"mcp_server_present":true,"loaded_mcp_tools":["a2a","mcp__other-server__other-tool"]}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatHandler_PlatformManagementMCPLoaded_StaysOnline verifies that
|
||||
// a platform concierge reporting the literal required create_workspace tool
|
||||
// in loaded_mcp_tools stays online. (The previous test loaded the plugin
|
||||
// NAME as a fake tool — that was a no-op false-green; this test pins the
|
||||
// real contract.)
|
||||
func TestHeartbeatHandler_PlatformManagementMCPLoaded_StaysOnline(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-mcp-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online"))
|
||||
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-mcp-ok", 0.0, "", 0, 60, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT status, kind, last_register_failure_at FROM workspaces WHERE id =").
|
||||
WithArgs("ws-mcp-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "kind", "last_register_failure_at"}).AddRow("online", "platform", nil))
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-mcp-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
mock.ExpectQuery("SELECT plugin_name, source_raw FROM workspace_declared_plugins").
|
||||
WithArgs("ws-mcp-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plugin_name", "source_raw"}).
|
||||
AddRow(conciergePlatformMCPName, "gitea://molecule-ai/molecule-ai-plugin-molecule-platform-mcp#main"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// loaded_mcp_tools carries the literal required tool identifier.
|
||||
body := `{"workspace_id":"ws-mcp-ok","error_rate":0.0,"sample_error":"","active_tasks":0,"uptime_seconds":60,"mcp_server_present":true,"loaded_mcp_tools":["a2a","` + conciergePlatformMCPCreateWorkspaceTool + `"]}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatHandler_RuntimeEmitsServerPresentButNoLoadedTools_Degraded
|
||||
// pins the CR2+Researcher fail-loud behavior: a runtime that speaks the
|
||||
// #147 contract (mcp_server_present=true) but does NOT report the new
|
||||
// loaded_mcp_tools producer cannot prove the management MCP is actually
|
||||
// loaded — flip to degraded instead of silent-skip. The previous
|
||||
// "old-runtime stays online" test was the false-green #3082 exists to
|
||||
// catch; the new contract says: if you can prove server-up, prove tools
|
||||
// too, or fail loud.
|
||||
func TestHeartbeatHandler_RuntimeEmitsServerPresentButNoLoadedTools_Degraded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-server-present-no-tools").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online"))
|
||||
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-server-present-no-tools", 0.0, "", 0, 60, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT status, kind, last_register_failure_at FROM workspaces WHERE id =").
|
||||
WithArgs("ws-server-present-no-tools").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "kind", "last_register_failure_at"}).AddRow("online", "platform", nil))
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-server-present-no-tools").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
// Degraded UPDATE — runtime spoke server-present but omitted loaded_mcp_tools.
|
||||
mock.ExpectExec("UPDATE workspaces SET status =.*status = 'online'").
|
||||
WithArgs(models.StatusDegraded, "platform agent runtime did not report loaded_mcp_tools on a mcp_server_present=true heartbeat; cannot verify create_workspace tool is loaded — marking degraded (core#3082)", "ws-server-present-no-tools").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// mcp_server_present=true but loaded_mcp_tools absent — runtime needs a
|
||||
// loaded_mcp_tools producer. Until it does, every platform concierge
|
||||
// will be flagged degraded (which is the honest signal).
|
||||
body := `{"workspace_id":"ws-server-present-no-tools","error_rate":0.0,"sample_error":"","active_tasks":0,"uptime_seconds":60,"mcp_server_present":true}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatHandler_Pre147RuntimeNoMCPServerPresent_StaysOnline pins the
|
||||
// backward-compat path: a runtime that predates the #147 contract (neither
|
||||
// mcp_server_present nor loaded_mcp_tools) does NOT trigger the #3082 gate.
|
||||
// The earlier platformAgentMCPServerPresent nil-tolerance keeps legacy
|
||||
// runtimes serving until the runtime-side loaded_mcp_tools producer lands.
|
||||
func TestHeartbeatHandler_Pre147RuntimeNoMCPServerPresent_StaysOnline(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-pre-147").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online"))
|
||||
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-pre-147", 0.0, "", 0, 60, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT status, kind, last_register_failure_at FROM workspaces WHERE id =").
|
||||
WithArgs("ws-pre-147").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "kind", "last_register_failure_at"}).AddRow("online", "platform", nil))
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-pre-147").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
// No listDeclaredPlugins query — the #3082 gate is skipped entirely for
|
||||
// pre-#147 runtimes (mcp_server_present nil ⇒ platformAgentMCPServerPresent
|
||||
// returns true under nil-tolerance; the new gate requires
|
||||
// mcp_server_present != nil && *mcp_server_present).
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"workspace_id":"ws-pre-147","error_rate":0.0,"sample_error":"","active_tasks":0,"uptime_seconds":60}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatHandler_PlatformManagementMCPLookupError_FlipsOnlineToDegraded
|
||||
// verifies that a failure to read workspace_declared_plugins is fail-loud:
|
||||
// the workspace is marked degraded rather than staying online with an
|
||||
// unverified management MCP. This closes the false-green path where a broken
|
||||
// lookup silently looked healthy (CR2 #12653 follow-up).
|
||||
func TestHeartbeatHandler_PlatformManagementMCPLookupError_FlipsOnlineToDegraded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Initial heartbeat UPDATE.
|
||||
mock.ExpectQuery("SELECT COALESCE\\(current_task").
|
||||
WithArgs("ws-mcp-lookup-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online"))
|
||||
|
||||
mock.ExpectExec("UPDATE workspaces SET").
|
||||
WithArgs("ws-mcp-lookup-err", 0.0, "", 0, 60, "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// evaluateStatus: currentStatus=online, kind=platform.
|
||||
mock.ExpectQuery("SELECT status, kind, last_register_failure_at FROM workspaces WHERE id =").
|
||||
WithArgs("ws-mcp-lookup-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "kind", "last_register_failure_at"}).AddRow("online", "platform", nil))
|
||||
|
||||
// platformAgentHasModelSecret: model secret exists.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-mcp-lookup-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
// platformAgentManagementMCPLoaded: listDeclaredPlugins fails.
|
||||
mock.ExpectQuery("SELECT plugin_name, source_raw FROM workspace_declared_plugins").
|
||||
WithArgs("ws-mcp-lookup-err").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
// Degraded UPDATE — lookup failure must not silently look healthy.
|
||||
// Use AnyArg for the human-readable message so the test is not brittle
|
||||
// against the exact wrapped error string produced by listDeclaredPlugins.
|
||||
mock.ExpectExec("UPDATE workspaces SET status =.*status = 'online'").
|
||||
WithArgs(models.StatusDegraded, sqlmock.AnyArg(), "ws-mcp-lookup-err").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// WORKSPACE_DEGRADED broadcast.
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// Even though loaded_mcp_tools contains the required tool, the lookup error
|
||||
// takes precedence and the workspace must degrade.
|
||||
body := `{"workspace_id":"ws-mcp-lookup-err","error_rate":0.0,"sample_error":"","active_tasks":0,"uptime_seconds":60,"mcp_server_present":true,"loaded_mcp_tools":["` + conciergePlatformMCPCreateWorkspaceTool + `"]}`
|
||||
c.Request = httptest.NewRequest("POST", "/registry/heartbeat", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Heartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,28 @@ const conciergePlatformMCPSource = "gitea://molecule-ai/molecule-ai-plugin-molec
|
||||
// derivation, not the human label "molecule-platform-mcp".
|
||||
const conciergePlatformMCPName = "molecule-ai-plugin-molecule-platform-mcp"
|
||||
|
||||
// conciergePlatformMCPCreateWorkspaceTool is the literal MCP tool identifier
|
||||
// the platform concierge must surface for the post-online fail-loud gate
|
||||
// (core#3082) to consider the management MCP actually loaded. The Claude
|
||||
// Code dispatcher formats every MCP tool as `mcp__<server>__<tool>`; the
|
||||
// platform MCP server's install name derives to "molecule-platform" via
|
||||
// PluginNameFromSource(conciergePlatformMCPSource), so the create_workspace
|
||||
// tool's namespaced identifier is `mcp__molecule-platform__create_workspace`.
|
||||
// This is the SAME literal the staging concierge E2E (tests/e2e/test_staging_
|
||||
// concierge_creates_workspace_e2e.sh:4.5/6) probes for; pinning it as a
|
||||
// constant keeps the runtime gate and the E2E in lock-step so a drift in
|
||||
// one breaks both with the same signal.
|
||||
//
|
||||
// Why we don't match the server/plugin NAME here: the heartbeat's
|
||||
// loaded_mcp_tools list carries TOOL identifiers (mcp__<server>__<tool>),
|
||||
// not plugin names. The previous check (loadedSet[conciergePlatformMCPName])
|
||||
// was a no-op false-green — it matched the plugin NAME against a list of
|
||||
// TOOL strings, which would always be empty for the management MCP (the
|
||||
// plugin is named "molecule-ai-plugin-molecule-platform-mcp" while its
|
||||
// tools are namespaced "mcp__molecule-platform__*"). The literal-tool
|
||||
// match below is the actual contract the runtime must satisfy.
|
||||
const conciergePlatformMCPCreateWorkspaceTool = "mcp__molecule-platform__create_workspace"
|
||||
|
||||
// ensureConciergeProvider pins the concierge's LLM provider to `platform` (core
|
||||
// companion to ensureConciergeModel). It guarantees the env-level provider pin
|
||||
// that the runtime needs, independent of the template config.yaml (which is NOT
|
||||
|
||||
@@ -413,6 +413,53 @@ func (h *RegistryHandler) platformAgentMCPServerPresent(present *bool) bool {
|
||||
return present == nil || *present
|
||||
}
|
||||
|
||||
// platformAgentManagementMCPLoaded reports whether the concierge's declared
|
||||
// management MCP is actually loaded into the LLM's runtime tool list. It
|
||||
// returns true (caller marks degraded) only when:
|
||||
// - the workspace has the management plugin declared in
|
||||
// workspace_declared_plugins (the install NAME conciergePlatformMCPName),
|
||||
// AND
|
||||
// - the reported loaded tool list does NOT contain the literal required
|
||||
// tool identifier (conciergePlatformMCPCreateWorkspaceTool).
|
||||
//
|
||||
// Why this checks the TOOL identifier and not the plugin name: the heartbeat's
|
||||
// loaded_mcp_tools carries namespaced tool ids (`mcp__<server>__<tool>`), not
|
||||
// plugin names. The management MCP's server is "molecule-platform" (the
|
||||
// PluginNameFromSource derivation), so its create_workspace tool is
|
||||
// "mcp__molecule-platform__create_workspace" — a different value from the
|
||||
// plugin name "molecule-ai-plugin-molecule-platform-mcp". Comparing the
|
||||
// plugin NAME against TOOL ids was a no-op false-green (CR2 #12653).
|
||||
//
|
||||
// If the management plugin is not declared (non-platform workspace, or a
|
||||
// platform concierge before plugin reconciliation), it returns false (NOT
|
||||
// missing) so we don't false-alarm on workspaces that legitimately don't
|
||||
// declare it. Errors are returned to the caller and MUST be treated as
|
||||
// fail-loud/degraded — a failed lookup must not silently look healthy.
|
||||
func (h *RegistryHandler) platformAgentManagementMCPLoaded(ctx context.Context, workspaceID string, loaded []string) (bool, error) {
|
||||
declared, err := listDeclaredPlugins(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("declared-plugin lookup: %w", err)
|
||||
}
|
||||
|
||||
hasDeclaredManagement := false
|
||||
for _, d := range declared {
|
||||
if d.PluginName == conciergePlatformMCPName {
|
||||
hasDeclaredManagement = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDeclaredManagement {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, t := range loaded {
|
||||
if t == conciergePlatformMCPCreateWorkspaceTool {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// markWorkspaceFailed updates a workspace row to status='failed' and broadcasts
|
||||
// WORKSPACE_PROVISION_FAILED. It is a RegistryHandler-local fallback for the
|
||||
// fail-closed platform-agent identity gate; the WorkspaceHandler's
|
||||
@@ -1277,6 +1324,67 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
h.markWorkspaceFailed(ctx, payload.WorkspaceID, msg, reason)
|
||||
return
|
||||
}
|
||||
|
||||
// core#3082: post-online fail-loud for a missing declared management MCP.
|
||||
//
|
||||
// Triggered when the runtime AFFIRMATIVELY reports mcp_server_present=true
|
||||
// (the #147 contract). For pre-#147 runtimes where the field is nil,
|
||||
// platformAgentMCPServerPresent above already returned true under
|
||||
// backward-compat — we DO NOT run the #3082 check in that case so
|
||||
// legacy runtimes don't flip to degraded before the runtime-side
|
||||
// loaded_mcp_tools producer lands.
|
||||
//
|
||||
// Once triggered, the gate has two fail-loud paths:
|
||||
// - loaded_mcp_tools present but missing the required tool
|
||||
// (mcp__molecule-platform__create_workspace) → degraded.
|
||||
// - loaded_mcp_tools ABSENT (runtime says server is up but won't
|
||||
// report the tools list) → degraded. This is the fail-loud
|
||||
// behavior CR2+Researcher asked for: silent-skip when the runtime
|
||||
// doesn't speak the new contract is exactly the false-green
|
||||
// #3082 exists to catch. Runtime needs a loaded_mcp_tools
|
||||
// producer (tracked separately — see PR #3101 PM flag).
|
||||
if payload.MCPServerPresent != nil && *payload.MCPServerPresent {
|
||||
loaded := payload.LoadedMCPTools
|
||||
var (
|
||||
managementMissing bool
|
||||
mErr error
|
||||
absentToolsList bool
|
||||
)
|
||||
if loaded == nil {
|
||||
// Runtime speaks #147 (server_present=true) but omits the new
|
||||
// loaded_mcp_tools producer → we cannot verify the specific
|
||||
// required tool is loaded. Fail-loud.
|
||||
managementMissing = true
|
||||
absentToolsList = true
|
||||
} else {
|
||||
managementMissing, mErr = h.platformAgentManagementMCPLoaded(ctx, payload.WorkspaceID, loaded)
|
||||
}
|
||||
if mErr != nil {
|
||||
msg := fmt.Sprintf("platform agent declared management MCP lookup failed: %v; marking degraded (core#3082)", mErr)
|
||||
log.Printf("Heartbeat: %s (workspace=%s)", msg, payload.WorkspaceID)
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3 AND status = 'online'`, models.StatusDegraded, msg, payload.WorkspaceID); err != nil {
|
||||
log.Printf("Heartbeat: failed to mark %s degraded (management MCP lookup error): %v", payload.WorkspaceID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
|
||||
"management_mcp_lookup_failed": true,
|
||||
"sample_error": msg,
|
||||
})
|
||||
} else if managementMissing {
|
||||
msg := "platform agent management MCP declared but not loaded; marking degraded (core#3082)"
|
||||
if absentToolsList {
|
||||
msg = "platform agent runtime did not report loaded_mcp_tools on a mcp_server_present=true heartbeat; cannot verify create_workspace tool is loaded — marking degraded (core#3082)"
|
||||
}
|
||||
log.Printf("Heartbeat: %s (workspace=%s)", msg, payload.WorkspaceID)
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3 AND status = 'online'`, models.StatusDegraded, msg, payload.WorkspaceID); err != nil {
|
||||
log.Printf("Heartbeat: failed to mark %s degraded (management MCP missing): %v", payload.WorkspaceID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
|
||||
"management_mcp_missing": true,
|
||||
"loaded_mcp_tools_absent": absentToolsList,
|
||||
"sample_error": msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Self-reported runtime wedge: takes precedence over the error_rate
|
||||
|
||||
@@ -158,6 +158,23 @@ type HeartbeatPayload struct {
|
||||
// so the fail-closed platform-agent gate can block recovery paths that
|
||||
// would otherwise resurrect an mcp-less concierge (RCA #2970).
|
||||
MCPServerPresent *bool `json:"mcp_server_present,omitempty"`
|
||||
|
||||
// LoadedMCPTools is the list of namespaced MCP tool identifiers the
|
||||
// runtime reports as actually loaded for this workspace. For platform
|
||||
// concierges, core cross-checks this against the declared management
|
||||
// MCP so a missing plugin is surfaced as degraded instead of silent
|
||||
// (core#3082, CR2 #12653 fix). Each entry is a Claude Code dispatcher
|
||||
// id of the form `mcp__<server>__<tool>`; the platform MCP's required
|
||||
// tool is `mcp__molecule-platform__create_workspace` (see
|
||||
// conciergePlatformMCPCreateWorkspaceTool).
|
||||
//
|
||||
// On a heartbeat where mcp_server_present=true and LoadedMCPTools is
|
||||
// nil/omitted, the #3082 gate fails loud (degraded) — the runtime
|
||||
// spoke the #147 contract but omitted the new loaded_mcp_tools
|
||||
// producer, so we cannot verify the specific required tool is loaded.
|
||||
// Runtime needs a loaded_mcp_tools producer to make the deployed path
|
||||
// healthy (tracked separately — see PR #3101 PM flag).
|
||||
LoadedMCPTools []string `json:"loaded_mcp_tools,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeMetadata is the adapter-declared capability + override block
|
||||
|
||||
Reference in New Issue
Block a user