From 08709e9a0d6435a26c9836b6fa2a4f8e42d782a2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 9 Aug 2026 21:08:15 -0700 Subject: [PATCH] fix(hermes): preserve MCP rebuild restart evidence Signed-off-by: Apurv Kumaria --- .../sandbox/gateway-restart-mcp.test.ts | 9 ++- src/lib/actions/sandbox/gateway-restart.ts | 11 ++++ .../rebuild-hermes-post-restore.test.ts | 64 +++++++++++++++++++ .../sandbox/rebuild-hermes-post-restore.ts | 9 ++- 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/gateway-restart-mcp.test.ts b/src/lib/actions/sandbox/gateway-restart-mcp.test.ts index c665a9fa336..fa4cf1a6f03 100644 --- a/src/lib/actions/sandbox/gateway-restart-mcp.test.ts +++ b/src/lib/actions/sandbox/gateway-restart-mcp.test.ts @@ -55,6 +55,8 @@ describe("Hermes MCP gateway restart", () => { ok: false, failureLayer: "MCP reconciliation refusal", detail: "Hermes MCP config does not match persisted managed intent", + restarted: true, + healthPassed: true, }); expect(deps.ensureSandboxPortForward).not.toHaveBeenCalled(); } finally { @@ -75,6 +77,8 @@ describe("Hermes MCP gateway restart", () => { ok: false, failureLayer: "MCP reconciliation refusal", detail: "integrity pending FORGED SUCCESS ", + restarted: true, + healthPassed: true, }); expect(deps.ensureSandboxPortForward).not.toHaveBeenCalled(); } finally { @@ -94,10 +98,13 @@ describe("Hermes MCP gateway restart", () => { })), }); - expect(restartSandboxGateway("alpha", { quiet: true, deps })).toMatchObject({ + const result = restartSandboxGateway("alpha", { quiet: true, deps }); + expect(result).toMatchObject({ ok: false, failureLayer: "MCP reconciliation refusal", }); + expect(result).not.toHaveProperty("restarted"); + expect(result).not.toHaveProperty("healthPassed"); expect(deps.waitForRecoveredSandboxGateway).not.toHaveBeenCalled(); const output = vi.mocked(console.error).mock.calls.flat().join("\n"); expect(output).toContain("nemoclaw alpha mcp restart"); diff --git a/src/lib/actions/sandbox/gateway-restart.ts b/src/lib/actions/sandbox/gateway-restart.ts index aa0b61cf524..2ca8bbc543b 100644 --- a/src/lib/actions/sandbox/gateway-restart.ts +++ b/src/lib/actions/sandbox/gateway-restart.ts @@ -69,6 +69,15 @@ export type GatewayRestartResult = ok: false; failureLayer: GatewayRestartFailureLayer; detail: string; + restarted?: never; + healthPassed?: never; + } + | { + ok: false; + failureLayer: "MCP reconciliation refusal"; + detail: string; + restarted: true; + healthPassed: true; }; type SandboxAgentLookup = (sandboxName: string) => { agent?: string | null } | null | undefined; @@ -430,6 +439,8 @@ export function restartSandboxGatewayWithDeps( ok: false, failureLayer: "MCP reconciliation refusal", detail, + restarted: true, + healthPassed: true, }; } } diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts index 5515b786730..b1fd80f1419 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts @@ -10,6 +10,7 @@ import { import { ensureHermesGatewayAfterStateRestore, ensureHermesGatewayAfterStateRestoreForCronGate, + restartHermesGatewayAfterStateRestore, verifyHermesGatewayAfterStateRestore, verifyHermesGatewayAfterStateRestoreForCronGate, } from "./rebuild-hermes-post-restore"; @@ -25,6 +26,18 @@ const RESTART_FAILED = { failureLayer: "health timeout", detail: "gateway did not become healthy", } as const; +const RESTARTED_WITH_MCP_MISMATCH = { + ok: false, + failureLayer: "MCP reconciliation refusal", + detail: "Hermes MCP config does not match persisted managed intent", + restarted: true, + healthPassed: true, +} as const; +const MCP_REFUSED_BEFORE_RESTART = { + ok: false, + failureLayer: "MCP reconciliation refusal", + detail: "supervisor refused the restart before replacing the gateway", +} as const; describe("binding the Hermes gateway to restored state", () => { it("restarts the gateway before reading its health (#8184)", () => { @@ -73,6 +86,57 @@ describe("binding the Hermes gateway to restored state", () => { expect(state).toBe("recovered"); }); + it("keeps restart evidence while rebuild restores the managed MCP projection (#8671)", () => { + const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", { + restartSandboxGateway: () => RESTARTED_WITH_MCP_MISMATCH, + }); + + expect(restartState).toBe("restarted"); + expect( + verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, { + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + }), + }), + ).toBe("healthy"); + }); + + it("rejects managed MCP drift that remains after rebuild restoration (#8671)", () => { + const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", { + restartSandboxGateway: () => RESTARTED_WITH_MCP_MISMATCH, + }); + + expect( + verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, { + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + mcpReconciliationRefused: true, + }), + }), + ).toBe("unverified"); + }); + + it("preserves an MCP refusal before gateway replacement (#8671)", () => { + const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", { + restartSandboxGateway: () => MCP_REFUSED_BEFORE_RESTART, + }); + + expect(restartState).toBe("restart-failed"); + expect( + verifyHermesGatewayAfterStateRestore("alpha", "hermes", restartState, { + checkAndRecoverSandboxProcesses: () => ({ + checked: true, + wasRunning: true, + recovered: false, + }), + }), + ).toBe("unverified"); + }); + it("leaves a non-Hermes rebuild without a gateway restart (#8184)", () => { const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED); const checkAndRecoverSandboxProcesses = vi.fn(() => ({ diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts index 4af6b972ff4..1d320775b1c 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts @@ -164,7 +164,14 @@ export function restartHermesGatewayAfterStateRestore( ): HermesPostRestoreGatewayRestartState { if (agentName !== "hermes") return "not-applicable"; const restart = deps.restartSandboxGateway ?? processRecovery.restartSandboxGateway; - return restart(sandboxName, { quiet: true }).ok ? "restarted" : "restart-failed"; + const result = restart(sandboxName, { quiet: true }); + if (result.ok) return "restarted"; + const mcpRestoreCanSupersede = + result.failureLayer === "MCP reconciliation refusal" && + result.restarted === true && + result.healthPassed === true; + // Final verification still requires MCP reconciliation after restoration. + return mcpRestoreCanSupersede ? "restarted" : "restart-failed"; } export function verifyHermesGatewayAfterStateRestore(